CI / check (push) Canceled after 0s
- Sign-up emails a confirmation link (24h); unconfirmed accounts see a banner on the web with "send it again", and status in Settings. - Change email (needs the password): the new address gets a confirmation link and only replaces the old one once clicked; the old address gets a heads-up with a reset-password link. Pending changes can be cancelled. - Change password (needs the current one): signs out other sessions. - Password fields have Show/Hide on web and mobile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
621 lines
22 KiB
TypeScript
621 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { Suspense, useRef, useState } from 'react';
|
|
import useSWR from 'swr';
|
|
import {
|
|
SERVICE_LABELS,
|
|
SPOTIFY_LINK_PAGES,
|
|
STREAMING_SERVICES,
|
|
isQuietScrobbler,
|
|
type ConnectionDTO,
|
|
type MeDTO,
|
|
type NowPlayingProvider,
|
|
type TrackSearchResultDTO,
|
|
type UserSummaryDTO,
|
|
} from '@musicshare/shared';
|
|
import { Avatar } from '@/components/Avatar';
|
|
import { ServiceName } from '@/components/ServiceName';
|
|
import { SharingControls } from '@/components/SharingControls';
|
|
import { SupportCard } from '@/components/SupportLink';
|
|
import { api, client, fetcher, uploadFile, useMe } from '@/lib/api';
|
|
import { timeAgo } from '@musicshare/shared';
|
|
import { useDebounced } from '@/lib/useDebounced';
|
|
|
|
export default function SettingsPage() {
|
|
return (
|
|
<Suspense>
|
|
<Settings />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function Settings() {
|
|
const { me, loggedOut } = useMe();
|
|
const router = useRouter();
|
|
if (loggedOut) router.replace('/login');
|
|
if (!me) return null;
|
|
return (
|
|
<div className="stack">
|
|
<h1>Settings</h1>
|
|
<ProfileForm me={me} />
|
|
<AccountSettings me={me} />
|
|
<SharingControls />
|
|
<Connections />
|
|
<FeaturedPicker />
|
|
<BlockedAccounts />
|
|
<SupportCard />
|
|
<DangerZone />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProfileForm({ me }: { me: MeDTO }) {
|
|
const { mutate } = useMe();
|
|
const [saved, setSaved] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const f = new FormData(e.currentTarget);
|
|
const str = (k: string) => (f.get(k) as string).trim() || null;
|
|
try {
|
|
const updated = await api<MeDTO>('/me', {
|
|
method: 'PATCH',
|
|
body: { displayName: str('displayName'), bio: str('bio'), preferredService: str('preferredService') },
|
|
});
|
|
await mutate(updated, { revalidate: false });
|
|
setError(null);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 2000);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form className="card form" onSubmit={onSubmit}>
|
|
<h2>Profile</h2>
|
|
<AvatarPicker me={me} />
|
|
<label>
|
|
Display name
|
|
<input name="displayName" defaultValue={me.displayName ?? ''} maxLength={64} />
|
|
</label>
|
|
<label>
|
|
Bio
|
|
<textarea name="bio" defaultValue={me.bio ?? ''} maxLength={280} rows={2} />
|
|
</label>
|
|
<label>
|
|
Open songs in
|
|
<select name="preferredService" defaultValue={me.preferredService ?? ''}>
|
|
<option value="">Ask every time</option>
|
|
{STREAMING_SERVICES.map((s) => (
|
|
<option key={s} value={s}>
|
|
{SERVICE_LABELS[s]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{error && <p className="error">{error}</p>}
|
|
<button className="btn btn-primary">{saved ? 'Saved ✓' : 'Save'}</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function AvatarPicker({ me }: { me: MeDTO }) {
|
|
const { mutate } = useMe();
|
|
const input = useRef<HTMLInputElement>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const run = async (fn: () => Promise<MeDTO>) => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await mutate(await fn(), { revalidate: false });
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
}
|
|
setBusy(false);
|
|
};
|
|
|
|
const onPick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = ''; // picking the same file again should still upload
|
|
if (!file) return;
|
|
if (file.size > 10 * 1024 * 1024) return setError('That picture is too big (10 MB max)');
|
|
run(() => uploadFile<MeDTO>('/me/avatar', file));
|
|
};
|
|
|
|
return (
|
|
<div className="avatar-picker">
|
|
<Avatar user={me} size={72} />
|
|
<div className="avatar-picker-actions">
|
|
<div className="row">
|
|
<button type="button" className="btn" disabled={busy} onClick={() => input.current?.click()}>
|
|
{busy ? 'Uploading…' : me.avatarUrl ? 'Change picture' : 'Add a picture'}
|
|
</button>
|
|
{me.avatarUrl && (
|
|
<button type="button" className="btn btn-small" disabled={busy} onClick={() => run(() => api<MeDTO>('/me/avatar', { method: 'DELETE' }))}>
|
|
Remove
|
|
</button>
|
|
)}
|
|
</div>
|
|
{error && <p className="error">{error}</p>}
|
|
</div>
|
|
<input ref={input} type="file" accept="image/*" hidden onChange={onPick} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AccountSettings({ me }: { me: MeDTO }) {
|
|
const { mutate } = useMe();
|
|
const [editing, setEditing] = useState<'email' | 'password' | null>(null);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [show, setShow] = useState(false);
|
|
|
|
const run = async (fn: () => Promise<unknown>, done: string) => {
|
|
setError(null);
|
|
try {
|
|
await fn();
|
|
await mutate();
|
|
setEditing(null);
|
|
setMessage(done);
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
}
|
|
};
|
|
const field = (f: FormData, k: string) => String(f.get(k) ?? '');
|
|
|
|
return (
|
|
<section className="card form">
|
|
<h2>Account</h2>
|
|
<div>
|
|
<div className="muted">Email</div>
|
|
<div>
|
|
{me.email}{' '}
|
|
{me.emailVerified ? <span className="chip-small">Confirmed</span> : <span className="chip-small danger">Not confirmed</span>}
|
|
</div>
|
|
{me.pendingEmail && (
|
|
<p className="muted">
|
|
Changing to <strong>{me.pendingEmail}</strong>: open the link we sent there.{' '}
|
|
<button type="button" className="link-button" onClick={() => run(() => client.cancelEmailChange(), 'Email change cancelled.')}>
|
|
Cancel
|
|
</button>
|
|
</p>
|
|
)}
|
|
{!me.emailVerified && !me.pendingEmail && (
|
|
<button type="button" className="link-button" onClick={() => run(() => client.resendVerification(), `Sent a new link to ${me.email}.`)}>
|
|
Send the confirmation link again
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{editing === 'email' ? (
|
|
<form
|
|
className="form"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const f = new FormData(e.currentTarget);
|
|
const email = field(f, 'email');
|
|
run(() => client.changeEmail({ email, password: field(f, 'password') }), `Check ${email} for a confirmation link.`);
|
|
}}
|
|
>
|
|
<label>
|
|
New email
|
|
<input name="email" type="email" required autoComplete="email" />
|
|
</label>
|
|
<label>
|
|
Your password
|
|
<input name="password" type={show ? 'text' : 'password'} required autoComplete="current-password" />
|
|
</label>
|
|
<ShowPassword show={show} setShow={setShow} />
|
|
<FormButtons label="Send confirmation link" onCancel={() => setEditing(null)} />
|
|
</form>
|
|
) : editing === 'password' ? (
|
|
<form
|
|
className="form"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const f = new FormData(e.currentTarget);
|
|
run(
|
|
() => client.changePassword({ currentPassword: field(f, 'current'), newPassword: field(f, 'new') }),
|
|
'Password changed. Your other devices were signed out.',
|
|
);
|
|
}}
|
|
>
|
|
<label>
|
|
Current password
|
|
<input name="current" type={show ? 'text' : 'password'} required autoComplete="current-password" />
|
|
</label>
|
|
<label>
|
|
New password
|
|
<input name="new" type={show ? 'text' : 'password'} required minLength={8} maxLength={200} autoComplete="new-password" />
|
|
</label>
|
|
<ShowPassword show={show} setShow={setShow} />
|
|
<FormButtons label="Change password" onCancel={() => setEditing(null)} />
|
|
</form>
|
|
) : (
|
|
<div className="row">
|
|
<button type="button" className="btn" onClick={() => (setMessage(null), setEditing('email'))}>
|
|
Change email
|
|
</button>
|
|
<button type="button" className="btn" onClick={() => (setMessage(null), setEditing('password'))}>
|
|
Change password
|
|
</button>
|
|
</div>
|
|
)}
|
|
{message && <p className="muted">{message}</p>}
|
|
{error && <p className="error">{error}</p>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ShowPassword({ show, setShow }: { show: boolean; setShow: (v: boolean) => void }) {
|
|
return (
|
|
<label className="checkbox">
|
|
<input type="checkbox" checked={show} onChange={(e) => setShow(e.target.checked)} /> Show passwords
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function FormButtons({ label, onCancel }: { label: string; onCancel: () => void }) {
|
|
return (
|
|
<div className="row">
|
|
<button className="btn btn-primary">{label}</button>
|
|
<button type="button" className="btn" onClick={onCancel}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BlockedAccounts() {
|
|
const { data: blocked = [], mutate } = useSWR<UserSummaryDTO[]>('/me/blocks', fetcher);
|
|
return (
|
|
<section className="card">
|
|
<h2>Blocked accounts</h2>
|
|
{blocked.length === 0 ? (
|
|
<p className="muted">You haven't blocked anyone. Block someone from the ⋯ menu on their profile.</p>
|
|
) : (
|
|
<ul className="people">
|
|
{blocked.map((u) => (
|
|
<li key={u.id}>
|
|
<Link href={`/u/${u.username}`} className="user-chip">
|
|
<Avatar user={u} size={36} />
|
|
<span className="user-name">{u.displayName ?? u.username}</span>
|
|
</Link>
|
|
<button className="btn btn-small" onClick={async () => (await client.unblock(u.username), mutate())}>
|
|
Unblock
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Connections() {
|
|
const params = useSearchParams();
|
|
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections', fetcher);
|
|
const [error, setError] = useState<string | null>(params.get('error'));
|
|
const byProvider = new Map(connections.map((c) => [c.provider, c]));
|
|
|
|
const run = async (fn: () => Promise<unknown>) => {
|
|
setError(null);
|
|
try {
|
|
await fn();
|
|
await mutate();
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
}
|
|
};
|
|
|
|
const connectOAuth = (provider: 'plex' | 'lastfm') =>
|
|
run(async () => {
|
|
const { url } = await api<{ url: string }>(`/connections/${provider}/authorize`, { body: { client: 'web' } });
|
|
window.location.href = url;
|
|
});
|
|
|
|
const connectJellyfin = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const body = Object.fromEntries(new FormData(e.currentTarget));
|
|
run(() => api('/connections/jellyfin', { body }));
|
|
};
|
|
|
|
const connectUsername = (provider: 'lastfm' | 'listenbrainz') => (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const username = new FormData(e.currentTarget).get('username');
|
|
run(() => api(`/connections/${provider}`, { body: { username } }));
|
|
};
|
|
|
|
const disconnect = (provider: NowPlayingProvider) =>
|
|
run(() => api(`/connections/${provider}`, { method: 'DELETE' }));
|
|
|
|
const status = (p: NowPlayingProvider) => {
|
|
const c = byProvider.get(p);
|
|
if (!c) return null;
|
|
return (
|
|
<span className={c.status === 'error' ? 'error' : 'muted'}>
|
|
{c.status === 'error'
|
|
? `Needs reconnecting: ${c.lastError}`
|
|
: `Connected as ${c.displayName ?? c.externalUserId}${c.lastPolledAt ? ` · checked ${timeAgo(c.lastPolledAt)}` : ''}`}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<section className="card">
|
|
<h2>Music services</h2>
|
|
<p className="muted">Connect where you listen. Your friends see what you're playing in real time.</p>
|
|
{error && <p className="error">{error}</p>}
|
|
<div className="connections">
|
|
{/* Spotify caps new apps at a handful of accounts; Spotify's own Last.fm/ListenBrainz link works for everyone. */}
|
|
<div className="connection spotify-how">
|
|
<div>
|
|
<ServiceName service="spotify" />
|
|
<div className="muted">
|
|
Spotify sends everything you play to <strong>Last.fm</strong> or <strong>ListenBrainz</strong>, and we share it from
|
|
there. Connect one of them below, then link Spotify on their site:
|
|
</div>
|
|
<ol className="steps">
|
|
<li>
|
|
<strong>Last.fm</strong> (most popular): sign in below, then{' '}
|
|
<a href={SPOTIFY_LINK_PAGES.lastfm} target="_blank" rel="noreferrer">
|
|
connect “Spotify Scrobbling” on Last.fm
|
|
</a>
|
|
.
|
|
</li>
|
|
<li>
|
|
<strong>ListenBrainz</strong> (open source): add your username below, then{' '}
|
|
<a href={SPOTIFY_LINK_PAGES.listenbrainz} target="_blank" rel="noreferrer">
|
|
connect Spotify on ListenBrainz
|
|
</a>
|
|
.
|
|
</li>
|
|
</ol>
|
|
<div className="muted">On Android, the musicshare app shares Spotify automatically.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="connection">
|
|
<div>
|
|
<ServiceName service="plex" />
|
|
<div>{status('plex') ?? <span className="muted">Music you play from your Plex servers</span>}</div>
|
|
<WebhookHint connection={byProvider.get('plex')} />
|
|
</div>
|
|
{byProvider.get('plex')?.status === 'active' ? (
|
|
<button className="btn" onClick={() => disconnect('plex')}>
|
|
Disconnect
|
|
</button>
|
|
) : (
|
|
<button className="btn btn-primary" onClick={() => connectOAuth('plex')}>
|
|
{byProvider.has('plex') ? 'Reconnect' : 'Connect'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="connection">
|
|
<div className="grow">
|
|
<ServiceName service="jellyfin" />
|
|
<div>{status('jellyfin') ?? <span className="muted">Sign in to your Jellyfin server. Your password isn't stored.</span>}</div>
|
|
<WebhookHint connection={byProvider.get('jellyfin')} />
|
|
{byProvider.get('jellyfin')?.status !== 'active' && (
|
|
<form className="jellyfin-form" onSubmit={connectJellyfin}>
|
|
<input name="serverUrl" placeholder="https://jellyfin.example.com" required type="url" />
|
|
<input name="username" placeholder="Username" required autoComplete="off" />
|
|
<input name="password" placeholder="Password" type="password" autoComplete="new-password" />
|
|
<button className="btn btn-primary">Connect</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
{byProvider.get('jellyfin')?.status === 'active' && (
|
|
<button className="btn" onClick={() => disconnect('jellyfin')}>
|
|
Disconnect
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{connections.filter((c) => isQuietScrobbler(c)).map((c) => (
|
|
<div className="notice" key={`quiet-${c.provider}`}>
|
|
Nothing new from {SERVICE_LABELS[c.provider]} in over a week. If you listen on Spotify, its link to{' '}
|
|
{SERVICE_LABELS[c.provider]} expires every 6 months.{' '}
|
|
<a href={SPOTIFY_LINK_PAGES[c.provider as 'lastfm' | 'listenbrainz']} target="_blank" rel="noreferrer">
|
|
Re-link Spotify
|
|
</a>
|
|
.
|
|
</div>
|
|
))}
|
|
{(['lastfm', 'listenbrainz'] as const).map((p) => (
|
|
<div className="connection" key={p}>
|
|
<div>
|
|
<ServiceName service={p} />
|
|
<div>
|
|
{status(p) ?? (
|
|
<span className="muted">
|
|
{p === 'lastfm'
|
|
? 'Works with anything that scrobbles: YouTube Music, Tidal, Deezer, SoundCloud…'
|
|
: 'Open-source scrobbling'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{byProvider.has(p) ? (
|
|
<div className="row">
|
|
{p === 'lastfm' && (
|
|
<a className="btn" href="https://www.last.fm/settings/applications" target="_blank" rel="noreferrer">
|
|
Link Spotify
|
|
</a>
|
|
)}
|
|
<button className="btn" onClick={() => disconnect(p)}>
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
) : p === 'lastfm' ? (
|
|
<button className="btn btn-primary" onClick={() => connectOAuth('lastfm')}>
|
|
Sign in with Last.fm
|
|
</button>
|
|
) : (
|
|
<form className="row" onSubmit={connectUsername(p)}>
|
|
<input name="username" placeholder="Username" required />
|
|
<button className="btn btn-primary">Connect</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<div className={`connection ${byProvider.has('apple_music') ? '' : 'disabled'}`}>
|
|
<div>
|
|
<ServiceName service="apple_music" />
|
|
<div className="muted">{byProvider.has('apple_music') ? status('apple_music') : 'Connect from the musicshare iPhone app (one tap)'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const WEBHOOK_HELP: Partial<Record<NowPlayingProvider, string>> = {
|
|
plex: 'Plex Pass: add this under Plex → Settings → Webhooks for instant updates.',
|
|
jellyfin: 'Optional: add this URL in the Jellyfin Webhook plugin (Playback Start/Stop) for instant updates.',
|
|
};
|
|
|
|
function WebhookHint({ connection }: { connection: ConnectionDTO | undefined }) {
|
|
const [copied, setCopied] = useState(false);
|
|
if (!connection?.webhookUrl || connection.status !== 'active') return null;
|
|
const copy = async () => {
|
|
await navigator.clipboard.writeText(connection.webhookUrl!);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
};
|
|
return (
|
|
<details className="webhook">
|
|
<summary>Instant updates</summary>
|
|
<p className="muted">{WEBHOOK_HELP[connection.provider]}</p>
|
|
<div className="row">
|
|
<code>{connection.webhookUrl}</code>
|
|
<button type="button" className="btn btn-small" onClick={copy}>
|
|
{copied ? 'Copied' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function FeaturedPicker() {
|
|
const { me } = useMe();
|
|
const [q, setQ] = useState('');
|
|
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(null);
|
|
const [comment, setComment] = useState('');
|
|
const [saved, setSaved] = useState(false);
|
|
const debounced = useDebounced(q.trim(), 300);
|
|
const { data: results } = useSWR<TrackSearchResultDTO[]>(
|
|
debounced.length >= 2 && !selected ? `/tracks/search?q=${encodeURIComponent(debounced)}` : null,
|
|
fetcher,
|
|
);
|
|
|
|
const save = async () => {
|
|
if (!selected) return;
|
|
await api('/me/featured', { method: 'PUT', body: { deezerId: selected.deezerId, comment: comment || null } });
|
|
setSaved(true);
|
|
};
|
|
|
|
return (
|
|
<section className="card form">
|
|
<h2>Featured song</h2>
|
|
<p className="muted">Pin a song to your profile, with an optional note about why.</p>
|
|
{selected ? (
|
|
<div className="picked">
|
|
{selected.artworkUrl && <img className="artwork-sm" src={selected.artworkUrl} alt="" />}
|
|
<div className="grow">
|
|
<div className="track-title">{selected.title}</div>
|
|
<div className="muted">{selected.artist}</div>
|
|
</div>
|
|
<button
|
|
className="link-button"
|
|
onClick={() => {
|
|
setSelected(null);
|
|
setSaved(false);
|
|
}}
|
|
>
|
|
Change
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<input placeholder="Search for a song" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
{results && (
|
|
<ul className="search-results">
|
|
{results.map((r) => (
|
|
<li key={r.deezerId}>
|
|
<button className="result" onClick={() => setSelected(r)}>
|
|
{r.artworkUrl && <img className="artwork-sm" src={r.artworkUrl} alt="" />}
|
|
<span>
|
|
{r.title} <span className="muted">· {r.artist}</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</>
|
|
)}
|
|
{selected && (
|
|
<>
|
|
<textarea
|
|
placeholder="Say something about it (optional)"
|
|
maxLength={280}
|
|
rows={2}
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
/>
|
|
<div className="row">
|
|
<button className="btn btn-primary" onClick={save}>
|
|
{saved ? 'Featured ✓' : 'Feature this song'}
|
|
</button>
|
|
{saved && me && (
|
|
<a href={`/u/${me.username}`} className="muted">
|
|
View profile →
|
|
</a>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function DangerZone() {
|
|
const [confirming, setConfirming] = useState(false);
|
|
const router = useRouter();
|
|
const { mutate } = useMe();
|
|
const remove = async () => {
|
|
await api('/me', { method: 'DELETE' });
|
|
await mutate(undefined, { revalidate: true });
|
|
router.push('/');
|
|
};
|
|
return (
|
|
<section className="card">
|
|
<h2>Delete account</h2>
|
|
<p className="muted">Permanently removes your profile, follows and connected services.</p>
|
|
{confirming ? (
|
|
<div className="row">
|
|
<button className="btn btn-danger" onClick={remove}>
|
|
Yes, delete my account
|
|
</button>
|
|
<button className="btn" onClick={() => setConfirming(false)}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button className="btn" onClick={() => setConfirming(true)}>
|
|
Delete account…
|
|
</button>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|