Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
292 changes: 282 additions & 10 deletions Cargo.lock

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions apps/frontend/app/(loginform)/confirm-email/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

import { LoginIcon } from '@components/ui/login-icon';
import { Button } from '@components/ui/button';
import { IconCheck, IconMail, IconX } from '@tabler/icons-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';

function ConfirmEmail() {
const searchParams = useSearchParams();
const status = searchParams.get('status');
const reason = searchParams.get('reason');

if (status === 'success') {
return (
<div className="flex flex-col items-center gap-4">
<LoginIcon><IconCheck /></LoginIcon>
<div className="mt-4 flex flex-col gap-1 text-center">
<h1 className="font-semibold text-xl">Email confirmed!</h1>
<p className="text-sm text-muted-foreground">Your email address has been verified.</p>
</div>
<div className="mt-2">
<Button asChild>
<Link href="/dashboard">Go to dashboard</Link>
</Button>
</div>
</div>
);
}

if (status === 'error') {
const message = reason === 'expired'
? 'This confirmation link has expired.'
: 'This confirmation link is invalid or has already been used.';

return (
<div className="flex flex-col items-center gap-4">
<LoginIcon><IconX /></LoginIcon>
<div className="mt-4 flex flex-col gap-1 text-center">
<h1 className="font-semibold text-xl">{reason === 'expired' ? 'Link expired' : 'Invalid link'}</h1>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
<div className="mt-2">
<Button variant="outline" asChild>
<Link href="/dashboard">Back to dashboard</Link>
</Button>
</div>
</div>
);
}

return (
<div className="flex flex-col items-center gap-4">
<LoginIcon><IconMail /></LoginIcon>
<div className="mt-4 flex flex-col gap-1 text-center">
<h1 className="font-semibold text-xl">Invalid link</h1>
<p className="text-sm text-muted-foreground">This confirmation link is missing required parameters.</p>
</div>
</div>
);
}

export default function Page() {
return (
<Suspense>
<ConfirmEmail />
</Suspense>
);
}
42 changes: 37 additions & 5 deletions apps/frontend/app/(loginform)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,49 @@ import { Input } from '@components/ui/input';
import { LoginIcon } from '@components/ui/login-icon';
import { LinkComponent } from '@components/ui/link';
import { $api } from '@lib/providers/api';
import { IconArrowRight, IconLock } from '@tabler/icons-react';
import { IconArrowRight, IconLock, IconLoader2 } from '@tabler/icons-react';
import { AnimatePresence, motion } from 'motion/react';
import Link from 'next/link';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSearchParams } from 'next/navigation';
import z from 'zod';

const schema = z.object({
email: z.string().email('Invalid email address'),
});
type Schema = z.infer<typeof schema>;

type Step = 'form' | 'sent';
type Step = 'form' | 'sending' | 'sent';

export default function Page() {
const [step, setStep] = useState<Step>('form');
const searchParams = useSearchParams();
const prefillEmail = searchParams.get('email') ?? '';
const isValidEmail = schema.safeParse({ email: prefillEmail }).success;
const [step, setStep] = useState<Step>(isValidEmail ? 'sending' : 'form');
const autoSubmitted = useRef(false);

const form = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: { email: '' },
defaultValues: { email: prefillEmail },
});

const requestReset = $api.useMutation('post', '/api/password-reset', {
onSuccess: () => setStep('sent'),
onError: (e) => {
setStep('form');
form.setError('email', { message: (e as any)?.error || 'Something went wrong.' });
},
});

useEffect(() => {
if (isValidEmail && !autoSubmitted.current) {
autoSubmitted.current = true;
requestReset.mutate({ body: { email: prefillEmail } });
}
}, []);

return (
<div className="flex flex-col items-center">
<AnimatePresence mode="wait" initial={false}>
Expand Down Expand Up @@ -94,6 +107,25 @@ export default function Page() {
</form>
</Form>
</motion.div>
) : step === 'sending' ? (
<motion.div
key="sending"
className="flex flex-col items-center w-full"
transition={{ duration: 0.2 }}
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
>
<LoginIcon>
<IconLoader2 className="animate-spin" />
</LoginIcon>
<div className="mt-4 flex flex-col gap-1">
<h1 className="font-semibold text-xl text-center">Sending reset link...</h1>
<p className="text-sm text-center text-muted-foreground">
Please wait while we send you a password reset email.
</p>
</div>
</motion.div>
) : (
<motion.div
key="sent"
Expand Down
7 changes: 5 additions & 2 deletions apps/frontend/app/(loginform)/login/login-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import {
IconPassword,
IconShieldLock,
} from '@tabler/icons-react';
import { screenAtom } from './page';
import { FormSchema, screenAtom } from './page';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { paths } from 'api-schema';
import { LoginOption, LoginOptionProps } from '@components/ui/login-option';
import Link from 'next/link';
import { LinkComponent } from '@components/ui/link';
import { useFormContext } from 'react-hook-form';

export type TLoginOption =
paths['/api/login/options']['get']['responses']['200']['content']['application/json']['options'][number];
Expand Down Expand Up @@ -39,6 +40,8 @@ export const OPTIONS_MAP: Record<TLoginOption, LoginOptionProps> = {
export function LoginOptions() {
const setScreen = useSetAtom(screenAtom);
const options = useAtomValue(optionsAtom);
const form = useFormContext<FormSchema>();
const username = form.watch('username');

return (
<div className="flex flex-col items-center">
Expand All @@ -64,7 +67,7 @@ export function LoginOptions() {
{options?.includes('password') && (
<div className="text-muted-foreground text-center text-sm mt-2">
<LinkComponent>
<Link href="/forgot-password">Forgot Password?</Link>
<Link href={`/forgot-password${username ? `?email=${encodeURIComponent(username)}` : ''}`}>Forgot Password?</Link>
</LinkComponent>
</div>
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/app/(loginform)/login/welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function Welcome() {
},
onError: (e) => {
form.setError('username', {
message: e?.error || 'Login failed.',
message: (e as { error?: string })?.error || 'Login failed.',
});
},
});
Expand Down
44 changes: 24 additions & 20 deletions apps/frontend/app/(loginform)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LinkComponent } from '@components/ui/link';
import z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { $api } from '@lib/providers/api';
import { getApiErrorMessage } from '@lib/api-error';
import { useCallback, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useRouter } from 'next/navigation';
Expand Down Expand Up @@ -54,33 +55,36 @@ export default function Page() {
},
});

const register = $api.useMutation('post', '/api/register', {
onSuccess: () => {
setStep('success');
setError(null);
},
onError: (e) => {
setError(e?.error || 'Registration failed.');
},
});
const register = $api.useMutation('post', '/api/register');

const handleNext = useCallback(async () => {
const valid = await form.trigger(['first_name', 'last_name', 'display_name']);
if (valid) setStep('credentials');
}, []);

const handleSubmit = form.handleSubmit((data) => {
const handleSubmit = form.handleSubmit(async (data) => {
setError(null);
register.mutate({
body: {
first_name: data.first_name,
last_name: data.last_name,
display_name: data.display_name,
preferred_username: data.preferred_username,
email: data.email,
password: data.password,
},
});
try {
const response = await register.mutateAsync({
body: {
first_name: data.first_name,
last_name: data.last_name,
display_name: data.display_name,
preferred_username: data.preferred_username,
email: data.email,
password: data.password,
},
});

if (!response?.success) {
setError('Registration failed.');
return;
}

setStep('success');
} catch (error) {
setError(getApiErrorMessage(error, 'Registration failed.'));
}
});

return (
Expand Down
37 changes: 37 additions & 0 deletions apps/frontend/app/dashboard/components/dashboard-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import { motion } from 'motion/react';
import { Button } from '@components/ui/button';
import { IconLogout, IconUser } from '@tabler/icons-react';

export function DashboardHeader({ profile, onLogout, loggingOut }: {
profile?: { display_name: string; email: string; email_confirmed: boolean };
onLogout: () => void;
loggingOut: boolean;
}) {
return (
<motion.div initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25 }}
className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold mb-0.5">Account Security</h1>
{profile ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<IconUser size={14} />
<span>{profile.display_name}</span>
<span className="text-muted-foreground/40">·</span>
<span>{profile.email}</span>
{!profile.email_confirmed && (
<span className="text-[10px] bg-destructive/10 text-destructive rounded-full px-1.5 py-0.5">Unverified</span>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Keep your account info up to date to ensure you always have access.</p>
)}
</div>
<Button variant="ghost" size="sm" onClick={onLogout} disabled={loggingOut} className="text-muted-foreground hover:text-foreground">
<IconLogout size={16} />
{loggingOut ? 'Logging out…' : 'Log out'}
</Button>
</motion.div>
);
}
31 changes: 31 additions & 0 deletions apps/frontend/app/dashboard/components/dashboard-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function DashboardSkeleton() {
return (
<div className="rounded-2xl border border-border bg-card">
{[...Array(5)].map((_, i) => (
<div key={i} className={`px-5 py-4 flex items-center gap-4 ${i < 4 ? 'border-b border-border/60' : ''}`}>
<div className="size-5 rounded bg-muted animate-pulse shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3.5 w-28 rounded bg-muted animate-pulse" />
<div className="h-3 w-40 rounded bg-muted animate-pulse" />
</div>
</div>
))}
</div>
);
}

export function DashboardError() {
return (
<div className="rounded-2xl border border-border bg-card px-5 py-4">
<p className="text-sm text-destructive">Failed to load - make sure you are signed in.</p>
</div>
);
}

export function DashboardWarning({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-border bg-card px-5 py-4">
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}
Loading
Loading