Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 72 additions & 45 deletions account-ui/src/components/PasskeyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Card from './Card';
import Button from './Button';
import Alert from './Alert';
import StatusDot from './StatusDot';
import PasswordPrompt from './PasswordPrompt';

const PasskeyCard: React.FC = () => {
const { data: passkeys, refetch } = useQuery({
Expand All @@ -18,12 +19,15 @@ const PasskeyCard: React.FC = () => {
const [addError, setAddError] = useState('');
const [deleteError, setDeleteError] = useState('');
const [isAdding, setIsAdding] = useState(false);
const [showAddPrompt, setShowAddPrompt] = useState(false);
const [showDeletePrompt, setShowDeletePrompt] = useState<string | null>(null);

const handleAdd = async () => {
const handleAdd = async (password: string) => {
setAddError('');
setIsAdding(true);
setShowAddPrompt(false);
try {
const beginRes = await api.post('/passkeys/register/begin');
const beginRes = await api.post('/passkeys/register/begin', { current_password: password });
const data = beginRes.data.data;
const credential = await performPasskeyRegistration(data);
await api.post(`/passkeys/register/finish?challenge_id=${data.challenge_id}`, credential);
Expand All @@ -39,60 +43,83 @@ const PasskeyCard: React.FC = () => {
}
};

const handleDelete = async (id: string) => {
const handleDelete = async (password: string) => {
if (!showDeletePrompt) return;
setDeleteError('');
const id = showDeletePrompt;
setShowDeletePrompt(null);
try {
await api.delete(`/passkeys/${id}`);
await api.delete(`/passkeys/${id}`, { data: { current_password: password } });
refetch();
} catch (err: unknown) {
setDeleteError(extractError(err, 'Failed to delete passkey.'));
}
};

return (
<Card
title="Passkeys"
description="Biometrics or security keys for passwordless login."
action={
<Button onClick={handleAdd} disabled={isAdding}>
{isAdding ? 'Registering…' : 'Add Passkey'}
</Button>
}
>
{addError && <Alert type="danger" message={addError} className="mb-2" />}
{deleteError && <Alert type="danger" message={deleteError} className="mb-2" />}
{passkeys && passkeys.length > 0 ? (
<div className="divide-y divide-theme-fg/10 mt-1">
{passkeys.map((pk: { id: string; name: string; created_at: string }) => (
<div key={pk.id} className="py-3.5 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-theme-body flex items-center justify-center flex-shrink-0">
<IconKey size={14} className="text-theme-fg" />
</div>
<div>
<p className="text-sm font-semibold">{pk.name || 'Unnamed Passkey'}</p>
<p className="text-xs text-theme-muted">
Added {new Date(pk.created_at).toLocaleDateString()}
</p>
<>
{showAddPrompt && (
<PasswordPrompt
title="Add Passkey"
message="Enter your password to register a new passkey."
confirmLabel="Continue"
onConfirm={handleAdd}
onCancel={() => setShowAddPrompt(false)}
/>
)}
{showDeletePrompt && (
<PasswordPrompt
title="Remove Passkey"
message="Enter your password to remove this passkey."
confirmLabel="Remove"
onConfirm={handleDelete}
onCancel={() => setShowDeletePrompt(null)}
/>
)}
<Card
title="Passkeys"
description="Biometrics or security keys for passwordless login."
action={
<Button onClick={() => setShowAddPrompt(true)} disabled={isAdding}>
{isAdding ? 'Registering…' : 'Add Passkey'}
</Button>
}
>
{addError && <Alert type="danger" message={addError} className="mb-2" />}
{deleteError && <Alert type="danger" message={deleteError} className="mb-2" />}
{passkeys && passkeys.length > 0 ? (
<div className="divide-y divide-theme-fg/10 mt-1">
{passkeys.map((pk: { id: string; name: string; created_at: string }) => (
<div key={pk.id} className="py-3.5 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-theme-body flex items-center justify-center flex-shrink-0">
<IconKey size={14} className="text-theme-fg" />
</div>
<div>
<p className="text-sm font-semibold">{pk.name || 'Unnamed Passkey'}</p>
<p className="text-xs text-theme-muted">
Added {new Date(pk.created_at).toLocaleDateString()}
</p>
</div>
</div>
<Button
variant="danger"
onClick={() => setShowDeletePrompt(pk.id)}
className="flex-shrink-0"
>
Remove
</Button>
</div>
<Button
variant="danger"
onClick={() => handleDelete(pk.id)}
className="flex-shrink-0"
>
Remove
</Button>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 mt-1">
<StatusDot active={false} />
<span className="text-sm text-theme-fg">No passkeys registered</span>
</div>
)}
</Card>
))}
</div>
) : (
<div className="flex items-center gap-2 mt-1">
<StatusDot active={false} />
<span className="text-sm text-theme-fg">No passkeys registered</span>
</div>
)}
</Card>
</>
);
};

Expand Down
70 changes: 70 additions & 0 deletions account-ui/src/components/PasswordPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import Modal from './Modal';
import Button from './Button';
import Alert from './Alert';

interface PasswordPromptProps {
title: string;
message: string;
confirmLabel?: string;
onConfirm: (password: string) => Promise<void>;
onCancel: () => void;
}

const PasswordPrompt: React.FC<PasswordPromptProps> = ({
title,
message,
confirmLabel = 'Confirm',
onConfirm,
onCancel,
}) => {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
await onConfirm(password);
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('An error occurred.');
}
} finally {
setIsSubmitting(false);
}
};

return (
<Modal title={title} onClose={onCancel}>
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-theme-muted">{message}</p>
<div>
<label>Password</label>
<input
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
</div>
{error && <Alert type="danger" message={error} />}
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={onCancel} className="flex-1">
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} className="flex-1">
{isSubmitting ? 'Verifying...' : confirmLabel}
</Button>
</div>
</form>
</Modal>
);
};

export default PasswordPrompt;
49 changes: 42 additions & 7 deletions account-ui/src/components/TotpSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,34 @@ interface TotpSetupModalProps {
}

const TotpSetupModal: React.FC<TotpSetupModalProps> = ({ onClose, onSuccess }) => {
const [step, setStep] = useState<'loading' | 'qr' | 'verify'>('loading');
const [step, setStep] = useState<'password' | 'loading' | 'qr' | 'verify'>('password');
const [secret, setSecret] = useState('');
const [qrData, setQrData] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
const [isVerifying, setIsVerifying] = useState(false);

React.useEffect(() => {
api.post('/mfa/totp/setup')
const beginSetup = (pw: string) => {
setStep('loading');
setError('');
api.post('/mfa/totp/setup', { current_password: pw })
.then((res) => {
setSecret(res.data.data.secret);
setQrData(res.data.data.qr_code_data);
setStep('qr');
})
.catch(() => {
setError('Failed to initialize TOTP setup');
setStep('qr');
.catch((err) => {
setError(extractError(err, 'Failed to initialize TOTP setup'));
setStep('password');
});
}, []);
};

const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault();
beginSetup(password);
};

const handleCopy = () => {
navigator.clipboard.writeText(secret).then(() => {
Expand All @@ -58,6 +66,33 @@ const TotpSetupModal: React.FC<TotpSetupModalProps> = ({ onClose, onSuccess }) =

return (
<Modal title="Set Up Authenticator" onClose={onClose}>
{step === 'password' && (
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<p className="text-sm text-theme-muted">
Enter your password to set up two-factor authentication.
</p>
<div>
<label>Password</label>
<input
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
</div>
{error && <Alert type="danger" message={error} />}
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1">
Continue
</Button>
</div>
</form>
)}

{step === 'loading' && (
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-theme-fg/20 border-t-theme-fg rounded-full animate-spin" />
Expand Down
53 changes: 46 additions & 7 deletions account-ui/src/pages/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import api from '../api';
import Card from '../components/Card';
import Button from '../components/Button';
import Alert from '../components/Alert';
import PasswordPrompt from '../components/PasswordPrompt';
import { useSettings } from '../context/SettingsContext';
import { extractError } from '../lib/utils';

Expand Down Expand Up @@ -67,6 +68,8 @@ const ProfilePage: React.FC = () => {
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false);
const [pendingPayload, setPendingPayload] = useState<Record<string, string> | null>(null);

useEffect(() => {
if (profile) {
Expand Down Expand Up @@ -96,15 +99,16 @@ const ProfilePage: React.FC = () => {
const set = (key: keyof ProfileForm) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }));

const handleUpdate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError('');
setSuccess('');
const needsPasswordConfirm = (payload: Record<string, string>) => {
if (!profile) return false;
const emailChanging = payload.email && payload.email !== profile.email;
const usernameChanging = payload.username && payload.username !== profile.username;
return emailChanging || usernameChanging;
};

const submitProfile = async (payload: Record<string, string>) => {
setIsUpdating(true);
try {
const payload = { ...form };
if (!settings.allow_username_change) delete (payload as Partial<ProfileForm>).username;
if (!settings.allow_email_change) delete (payload as Partial<ProfileForm>).email;
await api.put('/profile', payload);
setSuccess('Profile updated successfully.');
refetch();
Expand All @@ -115,6 +119,30 @@ const ProfilePage: React.FC = () => {
}
};

const handleUpdate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError('');
setSuccess('');
const payload: Record<string, string> = { ...form };
if (!settings.allow_username_change) delete payload.username;
if (!settings.allow_email_change) delete payload.email;

if (needsPasswordConfirm(payload)) {
setPendingPayload(payload);
setShowPasswordPrompt(true);
return;
}

await submitProfile(payload);
};

const handlePasswordConfirm = async (password: string) => {
if (!pendingPayload) return;
setShowPasswordPrompt(false);
await submitProfile({ ...pendingPayload, current_password: password });
setPendingPayload(null);
};

const showGivenName = settings.profile_field_given_name !== 'hidden';
const showFamilyName = settings.profile_field_family_name !== 'hidden';
const showMiddleName = settings.profile_field_middle_name !== 'hidden';
Expand All @@ -131,6 +159,16 @@ const ProfilePage: React.FC = () => {
const req = (field: string) => settings[`profile_field_${field}` as keyof typeof settings] === 'required';

return (
<>
{showPasswordPrompt && (
<PasswordPrompt
title="Confirm Password"
message="Enter your password to confirm this change."
confirmLabel="Save Changes"
onConfirm={handlePasswordConfirm}
onCancel={() => { setShowPasswordPrompt(false); setPendingPayload(null); }}
/>
)}
<Card title="Personal Information" description="Update your profile details.">
<form onSubmit={handleUpdate} className="space-y-5 mt-2">
<div>
Expand Down Expand Up @@ -253,6 +291,7 @@ const ProfilePage: React.FC = () => {
</div>
</form>
</Card>
</>
);
};

Expand Down
Loading
Loading