diff --git a/src/components/account/ProfileModal.tsx b/src/components/account/ProfileModal.tsx new file mode 100644 index 0000000..a310a4f --- /dev/null +++ b/src/components/account/ProfileModal.tsx @@ -0,0 +1,352 @@ +import type { User, UserIdentity } from '@supabase/supabase-js' +import { useEffect, useState } from 'react' +import { supabase } from '../../data/supabaseClient' +import { useAuthStore } from '../../state/authStore' +import Modal from '../common/Modal' + +/** Username changes don't need the current-password check below — unlike + * email/password, a wrong username doesn't lock anyone out of anything. */ +function UsernameSection({ currentUsername, userId }: { currentUsername: string; userId: string }) { + const loadAuth = useAuthStore((s) => s.load) + const [username, setUsername] = useState(currentUsername) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + + const trimmed = username.trim() + const canSave = trimmed.length > 0 && trimmed !== currentUsername + + const handleSave = async () => { + if (!canSave) return + setError(null) + setSaved(false) + setLoading(true) + try { + const { error } = await supabase.from('profiles').update({ username: trimmed }).eq('id', userId) + if (error) throw error.code === '23505' ? new Error('That username is already taken.') : error + await loadAuth() + setSaved(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+

Username

+
+ { + setUsername(e.target.value) + setSaved(false) + }} + className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-2 py-1 text-sm" + /> + +
+ {error &&

{error}

} + {saved && !error &&

Username updated.

} +
+ ) +} + +/** Email and password changes both require the current password up front + * (Supabase validates it server-side via `current_password`) — extra + * defense-in-depth beyond Supabase's own double-confirmation email flow, + * so a hijacked-but-unlocked session can't quietly lock the real owner + * out. Only rendered when the account actually has a password identity — + * see ProfileModal's hasPasswordIdentity check. */ +function EmailSection({ currentEmail }: { currentEmail: string }) { + const [newEmail, setNewEmail] = useState('') + const [currentPassword, setCurrentPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + const canSave = newEmail.trim().length > 0 && newEmail.trim() !== currentEmail && currentPassword.length > 0 + + const handleSave = async () => { + if (!canSave) return + setError(null) + setPending(false) + setLoading(true) + try { + const { error } = await supabase.auth.updateUser({ + email: newEmail.trim(), + current_password: currentPassword, + }) + if (error) throw error + setNewEmail('') + setCurrentPassword('') + setPending(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+

Email

+

Current: {currentEmail}

+ { + setNewEmail(e.target.value) + setPending(false) + }} + placeholder="New email" + className="w-full rounded border border-slate-300 px-2 py-1 text-sm" + /> + setCurrentPassword(e.target.value)} + placeholder="Current password" + className="w-full rounded border border-slate-300 px-2 py-1 text-sm" + /> + + {error &&

{error}

} + {pending && !error && ( +

+ Check both your old and new inbox — the change won't take effect until you confirm both. +

+ )} +
+ ) +} + +function PasswordSection() { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + + const canSave = currentPassword.length > 0 && newPassword.length >= 6 + + const handleSave = async () => { + if (!canSave) return + setError(null) + setSaved(false) + setLoading(true) + try { + const { error } = await supabase.auth.updateUser({ password: newPassword, current_password: currentPassword }) + if (error) throw error + setCurrentPassword('') + setNewPassword('') + setSaved(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+

Password

+ setCurrentPassword(e.target.value)} + placeholder="Current password" + className="w-full rounded border border-slate-300 px-2 py-1 text-sm" + /> + { + setNewPassword(e.target.value) + setSaved(false) + }} + placeholder="New password (min. 6 characters)" + className="w-full rounded border border-slate-300 px-2 py-1 text-sm" + /> + + {error &&

{error}

} + {saved && !error &&

Password updated.

} +
+ ) +} + +/** + * organized-ideas.md §2's self-service Google-account migration: link a new + * Google identity while still signed in with the old one, confirm it + * worked, then unlink the old one — same account/UUID throughout, so every + * owned diagram and private catalog entry carries over with zero data + * migration. (The other tier from that plan — a Super Admin tool for + * someone who's already lost access to their old Google account — is a + * separate, bigger feature: reassigning/merging accounts you don't own, + * not something this self-service section can help with.) + * + * Requires `enable_manual_linking = true` in supabase/config.toml — off by + * default since Supabase treats letting a user attach an arbitrary second + * identity as a deliberate opt-in, not something to enable silently. + */ +function GoogleIdentitiesSection() { + const [identities, setIdentities] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const loadIdentities = async () => { + const { data, error } = await supabase.auth.getUserIdentities() + if (error) { + setError(error.message) + return + } + setIdentities(data.identities) + } + + useEffect(() => { + supabase.auth.getUserIdentities().then(({ data, error }) => { + if (error) setError(error.message) + else setIdentities(data.identities) + }) + }, []) + + const googleIdentities = identities?.filter((i) => i.provider === 'google') ?? [] + // unlinkIdentity refuses to leave an account with zero identities — this + // mirrors that so the button reads as disabled rather than failing. + const canUnlink = (identities?.length ?? 0) > 1 + + const handleLink = async () => { + setError(null) + setLoading(true) + // Redirects the whole page to Google's consent screen on success — if + // we're still here, it failed before ever leaving. + const { error } = await supabase.auth.linkIdentity({ + provider: 'google', + options: { redirectTo: window.location.origin }, + }) + if (error) { + setError(error.message) + setLoading(false) + } + } + + const handleUnlink = async (identity: UserIdentity) => { + setError(null) + setLoading(true) + try { + const { error } = await supabase.auth.unlinkIdentity(identity) + if (error) throw error + await loadIdentities() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setLoading(false) + } + } + + return ( +
+

Google accounts

+

+ Moving to a new Google account? Link it here, sign out and confirm you can sign back in with it, then unlink + the old one — this stays the same account throughout, so nothing you own needs to move. +

+ {googleIdentities.length === 0 ? ( +

No Google account linked.

+ ) : ( +
    + {googleIdentities.map((identity) => ( +
  • + + {(identity.identity_data as { email?: string } | undefined)?.email ?? identity.id} + + +
  • + ))} +
+ )} + + {error &&

{error}

} +
+ ) +} + +/** Reachable from TopBar's Account menu — a home for anything about your + * own account, not any one diagram. Username always shown; email/password + * only for an account that actually has a password identity (a Google- + * only sign-in has neither — their email is Google's, and there's no + * password to change or use as the current-password check below). */ +export default function ProfileModal({ onClose }: { onClose: () => void }) { + const username = useAuthStore((s) => s.username) + const userId = useAuthStore((s) => s.userId) + const [user, setUser] = useState(undefined) + + useEffect(() => { + supabase.auth.getUser().then(({ data }) => setUser(data.user)) + }, []) + + const hasPasswordIdentity = !!user?.identities?.some((i) => i.provider === 'email') + + return ( + +
+ {username && userId && } + + {user === undefined ? ( +

Loading…

+ ) : hasPasswordIdentity ? ( + <> + + + + ) : ( +

+ You sign in with Google — your email and password are managed by your Google account. +

+ )} + + +
+ +
+ +
+
+ ) +} diff --git a/src/components/admin/AccountMigrationModal.tsx b/src/components/admin/AccountMigrationModal.tsx new file mode 100644 index 0000000..2d5cfd5 --- /dev/null +++ b/src/components/admin/AccountMigrationModal.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react' +import type { AccountMigrationImpact, AdminUserSummary } from '../../data/AdminUserRepository' +import { useAdminUserStore } from '../../state/adminUserStore' +import Modal from '../common/Modal' + +/** + * organized-ideas.md §2's Super-Admin tier for someone who's lost access to + * their old account entirely (the self-service link/unlink path in + * ProfileModal only covers "I still have access to the old account too"). + * Deliberately narrow: moves ownership of diagrams/private catalog + * entries/submissions, never touches credentials, never deletes the old + * account — see migrate_account_ownership()'s own migration comment for + * the full reasoning. + */ +export default function AccountMigrationModal({ + fromUser, + users, + onClose, +}: { + fromUser: AdminUserSummary + users: AdminUserSummary[] + onClose: () => void +}) { + const previewMigration = useAdminUserStore((s) => s.previewMigration) + const migrateAccount = useAdminUserStore((s) => s.migrateAccount) + // Already-migrated accounts are empty husks — not sensible migration + // targets themselves. + const candidates = users.filter((u) => u.id !== fromUser.id && !u.migratedToUserId) + const [toUserId, setToUserId] = useState(candidates[0]?.id ?? '') + const [notes, setNotes] = useState('') + const [preview, setPreview] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [done, setDone] = useState(false) + + const toUser = users.find((u) => u.id === toUserId) + + const handlePreview = async () => { + if (!toUserId) return + setError(null) + setPreviewLoading(true) + try { + setPreview(await previewMigration(fromUser.id, toUserId)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setPreviewLoading(false) + } + } + + const handleMigrate = async () => { + if (!toUserId || !notes.trim() || !preview) return + setError(null) + setBusy(true) + try { + await migrateAccount(fromUser.id, toUserId, notes.trim()) + setDone(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong.') + } finally { + setBusy(false) + } + } + + return ( + + {done ? ( +
+

+ Moved "{fromUser.username}"'s data to "{toUser?.username}". {fromUser.username}'s account still exists, + just empty now — nothing was deleted. +

+
+ +
+
+ ) : candidates.length === 0 ? ( +

No other account to migrate this into.

+ ) : ( +
+

+ Moves every diagram, private catalog entry, and submission owned by{' '} + {fromUser.username} to another account. Doesn't touch{' '} + {fromUser.username}'s login (email, password, Google identity) and doesn't delete the account — only use + this once you've verified, independently of anything in this app, that the person asking really is{' '} + {fromUser.username}. +

+ + + +