diff --git a/Cargo.lock b/Cargo.lock index 8c7a11a..2c3aa0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-client-ip" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ba1af5b620232acf37f2eb6d22151ea465491e0b4c25f552d1990f64ec5a67" +dependencies = [ + "axum", + "client-ip", + "serde", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -723,6 +734,15 @@ dependencies = [ "inout", ] +[[package]] +name = "client-ip" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39d2056bf065c8b4bce5a8898d40e175211ff4410add2a84d695845d3937c729" +dependencies = [ + "http", +] + [[package]] name = "cmac" version = "0.7.2" @@ -1142,6 +1162,20 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -1586,6 +1620,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1610,6 +1650,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + [[package]] name = "fred" version = "10.1.0" @@ -1735,6 +1785,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -1830,6 +1886,29 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "group" version = "0.13.0" @@ -1894,7 +1973,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1902,6 +1981,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2168,6 +2252,19 @@ dependencies = [ "webpki-roots 1.0.5", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -2504,6 +2601,21 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "k256" version = "0.13.4" @@ -2968,6 +3080,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3006,9 +3130,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -3382,6 +3506,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3518,6 +3652,26 @@ dependencies = [ "serde", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3725,6 +3879,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -3892,6 +4061,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redis-protocol" version = "6.0.0" @@ -4718,6 +4896,7 @@ dependencies = [ "async-trait", "auth_core", "axum", + "axum-client-ip", "axum-valid", "base32", "base64 0.22.1", @@ -4730,6 +4909,7 @@ dependencies = [ "hex", "http", "http-serde-ext", + "jsonwebtoken", "macros", "mail", "mongodb", @@ -4741,6 +4921,7 @@ dependencies = [ "rand_core 0.10.0", "regex", "reqwest 0.13.2", + "rsa", "sea-orm", "serde", "serde_json", @@ -4754,10 +4935,12 @@ dependencies = [ "tower-http", "tower-sessions", "tower-sessions-redis-store", + "tower_governor", "tracing", "tracing-error", "tracing-subscriber", "url", + "urlencoding", "utoipa", "utoipa-axum", "utoipa-rapidoc", @@ -4860,6 +5043,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + [[package]] name = "slab" version = "0.4.11" @@ -4931,6 +5126,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -5319,9 +5523,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -5329,22 +5533,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5517,6 +5721,35 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.1", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "totp-rs" version = "5.7.1" @@ -5665,6 +5898,23 @@ dependencies = [ "tower-sessions-core", ] +[[package]] +name = "tower_governor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.17", + "tonic", + "tower", + "tracing", +] + [[package]] name = "tracing" version = "0.1.44" @@ -6289,6 +6539,28 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/apps/frontend/app/(loginform)/confirm-email/page.tsx b/apps/frontend/app/(loginform)/confirm-email/page.tsx new file mode 100644 index 0000000..ccb697e --- /dev/null +++ b/apps/frontend/app/(loginform)/confirm-email/page.tsx @@ -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 ( +
+ +
+

Email confirmed!

+

Your email address has been verified.

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

{reason === 'expired' ? 'Link expired' : 'Invalid link'}

+

{message}

+
+
+ +
+
+ ); + } + + return ( +
+ +
+

Invalid link

+

This confirmation link is missing required parameters.

+
+
+ ); +} + +export default function Page() { + return ( + + + + ); +} diff --git a/apps/frontend/app/(loginform)/forgot-password/page.tsx b/apps/frontend/app/(loginform)/forgot-password/page.tsx index 4dc4a4f..35ccc3e 100644 --- a/apps/frontend/app/(loginform)/forgot-password/page.tsx +++ b/apps/frontend/app/(loginform)/forgot-password/page.tsx @@ -6,12 +6,13 @@ 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({ @@ -19,23 +20,35 @@ const schema = z.object({ }); type Schema = z.infer; -type Step = 'form' | 'sent'; +type Step = 'form' | 'sending' | 'sent'; export default function Page() { - const [step, setStep] = useState('form'); + const searchParams = useSearchParams(); + const prefillEmail = searchParams.get('email') ?? ''; + const isValidEmail = schema.safeParse({ email: prefillEmail }).success; + const [step, setStep] = useState(isValidEmail ? 'sending' : 'form'); + const autoSubmitted = useRef(false); const form = useForm({ 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 (
@@ -94,6 +107,25 @@ export default function Page() { + ) : step === 'sending' ? ( + + + + +
+

Sending reset link...

+

+ Please wait while we send you a password reset email. +

+
+
) : ( = { export function LoginOptions() { const setScreen = useSetAtom(screenAtom); const options = useAtomValue(optionsAtom); + const form = useFormContext(); + const username = form.watch('username'); return (
@@ -64,7 +67,7 @@ export function LoginOptions() { {options?.includes('password') && (
- Forgot Password? + Forgot Password?
)} diff --git a/apps/frontend/app/(loginform)/login/welcome.tsx b/apps/frontend/app/(loginform)/login/welcome.tsx index 7c2c409..fc652ee 100644 --- a/apps/frontend/app/(loginform)/login/welcome.tsx +++ b/apps/frontend/app/(loginform)/login/welcome.tsx @@ -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.', }); }, }); diff --git a/apps/frontend/app/(loginform)/register/page.tsx b/apps/frontend/app/(loginform)/register/page.tsx index fd85093..cf8b569 100644 --- a/apps/frontend/app/(loginform)/register/page.tsx +++ b/apps/frontend/app/(loginform)/register/page.tsx @@ -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'; @@ -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 ( diff --git a/apps/frontend/app/dashboard/components/dashboard-header.tsx b/apps/frontend/app/dashboard/components/dashboard-header.tsx new file mode 100644 index 0000000..89aa9f1 --- /dev/null +++ b/apps/frontend/app/dashboard/components/dashboard-header.tsx @@ -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 ( + +
+

Account Security

+ {profile ? ( +
+ + {profile.display_name} + · + {profile.email} + {!profile.email_confirmed && ( + Unverified + )} +
+ ) : ( +

Keep your account info up to date to ensure you always have access.

+ )} +
+ +
+ ); +} diff --git a/apps/frontend/app/dashboard/components/dashboard-status.tsx b/apps/frontend/app/dashboard/components/dashboard-status.tsx new file mode 100644 index 0000000..a13a296 --- /dev/null +++ b/apps/frontend/app/dashboard/components/dashboard-status.tsx @@ -0,0 +1,31 @@ +export function DashboardSkeleton() { + return ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function DashboardError() { + return ( +
+

Failed to load - make sure you are signed in.

+
+ ); +} + +export function DashboardWarning({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} diff --git a/apps/frontend/app/dashboard/components/delete-account-section.tsx b/apps/frontend/app/dashboard/components/delete-account-section.tsx new file mode 100644 index 0000000..dde00d5 --- /dev/null +++ b/apps/frontend/app/dashboard/components/delete-account-section.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import z from 'zod'; +import { $api } from '@lib/providers/api'; +import { useRouter } from 'next/navigation'; +import { Button } from '@components/ui/button'; +import { IconTrash } from '@tabler/icons-react'; +import { ExpandForm, PasswordInput } from './helpers'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@components/ui/form'; +import { useState } from 'react'; + +const deleteSchema = z.object({ + password: z.string().min(1, 'Required'), +}); +type DeleteForm = z.infer; + +export function DeleteAccountSection() { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(deleteSchema), + defaultValues: { password: '' }, + }); + + const deleteAccount = $api.useMutation('delete', '/api/settings/account', { + onSuccess: () => router.push('/login'), + onError: () => { + form.setError('password', { message: 'Incorrect password or failed to delete.' }); + }, + }); + + return ( +
+
+
+

Delete account

+

Permanently remove your account and all associated data.

+
+ {!open && ( + + )} +
+ +
+ deleteAccount.mutate({ body: data }))} className="mt-1 space-y-3 max-w-sm pb-1"> +

This action is irreversible. Enter your password to confirm.

+ ( + + + + + + + )} /> +
+ + +
+ + +
+
+ ); +} diff --git a/apps/frontend/app/dashboard/components/factor-key-item.tsx b/apps/frontend/app/dashboard/components/factor-key-item.tsx new file mode 100644 index 0000000..ffb208f --- /dev/null +++ b/apps/frontend/app/dashboard/components/factor-key-item.tsx @@ -0,0 +1,26 @@ +import { Button } from '@components/ui/button'; + +export function FactorKeyItem({ icon, name, subtitle, onRemove }: { + icon: React.ReactNode; + name: string; + subtitle: string; + onRemove: () => void; +}) { + return ( +
+
+
+ {icon} +
+
+

{name}

+

{subtitle}

+
+
+ +
+ ); +} diff --git a/apps/frontend/app/dashboard/components/factor-row.tsx b/apps/frontend/app/dashboard/components/factor-row.tsx new file mode 100644 index 0000000..74b3216 --- /dev/null +++ b/apps/frontend/app/dashboard/components/factor-row.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { motion } from 'motion/react'; +import { IconChevronRight } from '@tabler/icons-react'; + +export function StatusTag({ label, enabled }: { label: string; enabled: boolean }) { + return ( + + {label} + + ); +} + +export function FactorRow({ + icon, name, description, tag, onToggle, children, last, open, +}: { + icon: React.ReactNode; name: string; description: string; tag: { label: string; enabled: boolean }; onToggle?: () => void; children: React.ReactNode; last?: boolean; open?: boolean; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/apps/frontend/app/dashboard/components/helpers.tsx b/apps/frontend/app/dashboard/components/helpers.tsx new file mode 100644 index 0000000..f19bf08 --- /dev/null +++ b/apps/frontend/app/dashboard/components/helpers.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { IconCheck, IconCopy, IconEye, IconEyeOff } from '@tabler/icons-react'; +import { Input } from '@components/ui/input'; +import { Button } from '@components/ui/button'; + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +export function ErrorMsg({ msg }: { msg: string }) { + if (!msg) return null; + return

{msg}

; +} + +export function ExpandForm({ open, children }: { open: boolean; children: React.ReactNode }) { + const [overflow, setOverflow] = useState<'hidden' | 'visible'>('hidden'); + + useEffect(() => { + if (open) { + const t = setTimeout(() => setOverflow('visible'), 400); + return () => clearTimeout(t); + } else { + setOverflow('hidden'); + } + }, [open]); + + return ( + + {open && ( + +
{children}
+
+ )} +
+ ); +} + +export function SmoothResize({ children }: { children: React.ReactNode }) { + const ref = useRef(null); + const [height, setHeight] = useState('auto'); + + useEffect(() => { + if (!ref.current) return; + const ro = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height)); + ro.observe(ref.current); + return () => ro.disconnect(); + }, []); + + return ( + +
{children}
+
+ ); +} + +export function PasswordInput({ value, onChange, placeholder, required, minLength, id }: { + value: string; onChange: (v: string) => void; placeholder?: string; required?: boolean; minLength?: number; id?: string; +}) { + const [show, setShow] = useState(false); + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} className="h-9 pr-9 text-sm" required={required} minLength={minLength} id={id} /> + +
+ ); +} diff --git a/apps/frontend/app/dashboard/components/index.ts b/apps/frontend/app/dashboard/components/index.ts new file mode 100644 index 0000000..a0a0c5e --- /dev/null +++ b/apps/frontend/app/dashboard/components/index.ts @@ -0,0 +1,9 @@ +'use client'; + +export { PasswordRow } from './password-row'; +export { TotpRow } from './totp-row'; +export { WebAuthnRow } from './webauthn-row'; +export { PgpRow } from './pgp-row'; +export { RecoveryCodesRow } from './recovery-codes-row'; +export { ProfileRow } from './profile-row'; +export { DeleteAccountSection } from './delete-account-section'; diff --git a/apps/frontend/app/dashboard/components/password-row.tsx b/apps/frontend/app/dashboard/components/password-row.tsx new file mode 100644 index 0000000..776a7ac --- /dev/null +++ b/apps/frontend/app/dashboard/components/password-row.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import z from 'zod'; +import { $api } from '@lib/providers/api'; +import { getApiErrorMessage } from '@lib/api-error'; +import { Button } from '@components/ui/button'; +import { Label } from '@components/ui/label'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@components/ui/form'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@components/ui/dialog'; +import { IconLock } from '@tabler/icons-react'; +import { FactorRow } from './factor-row'; +import { ErrorMsg, PasswordInput } from './helpers'; + +const passwordSchema = z.object({ + current_password: z.string().optional(), + new_password: z.string().min(8, 'Min. 8 characters'), + confirm_password: z.string(), +}).refine(data => data.new_password === data.confirm_password, { + message: 'Passwords do not match', + path: ['confirm_password'], +}); +type PasswordForm = z.infer; + +export function PasswordRow({ isSet, onRefetch }: { isSet: boolean; onRefetch: () => void }) { + const [open, setOpen] = useState(false); + const [error, setError] = useState(''); + + const form = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { current_password: '', new_password: '', confirm_password: '' }, + }); + + const change = $api.useMutation('post', '/api/settings/password/change', { + onSuccess: () => { + form.reset(); + setError(''); + setOpen(false); + onRefetch(); + }, + onError: (error) => { + setError(getApiErrorMessage(error, 'Could not update the password.')); + }, + }); + + const onSubmit = (data: PasswordForm) => { + setError(''); + change.mutate({ body: { current_password: data.current_password ?? '', new_password: data.new_password } }); + }; + + const handleClose = (v: boolean) => { + if (!v) { form.reset(); setError(''); } + setOpen(v); + }; + + return ( + <> + } name="Password" description="Authenticate using a password." tag={{ label: isSet ? 'Enabled' : 'Disabled', enabled: isSet }} onToggle={() => setOpen(true)} open={false}> + <> + + + + + + {isSet ? 'Change password' : 'Set password'} + + {isSet ? 'Enter your current password and choose a new one.' : 'Set a password for your account.'} + + +
+ + {isSet && ( + ( + + + + + + + + )} + /> + )} + ( + + + + + + + + )} + /> + ( + + + + + + + + )} + /> + +
+ + +
+ + +
+
+ + ); +} diff --git a/apps/frontend/app/dashboard/components/pgp-row.tsx b/apps/frontend/app/dashboard/components/pgp-row.tsx new file mode 100644 index 0000000..263cf43 --- /dev/null +++ b/apps/frontend/app/dashboard/components/pgp-row.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import z from 'zod'; +import { $api } from '@lib/providers/api'; +import { Button } from '@components/ui/button'; +import { Label } from '@components/ui/label'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@components/ui/form'; +import { Input } from '@components/ui/input'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@components/ui/dialog'; +import { IconKey, IconPlus } from '@tabler/icons-react'; +import { FactorRow } from './factor-row'; +import { ExpandForm } from './helpers'; +import { FactorKeyItem } from './factor-key-item'; + +const pgpSchema = z.object({ + display_name: z.string().min(1, 'Required').max(32), + public_key: z.string().min(1, 'Required'), +}); +type PgpForm = z.infer; + +export function PgpRow({ pgp, onRefetch }: { pgp: { fingerprint: string; display_name: string }[]; onRefetch: () => void }) { + const [open, setOpen] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ fingerprint: string; display_name: string } | null>(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(pgpSchema), + defaultValues: { display_name: '', public_key: '' }, + }); + + const enable = $api.useMutation('post', '/api/settings/factors/pgp/enable', { + onSuccess: () => { + form.reset(); + setAddOpen(false); + onRefetch(); + }, + onError: () => { + form.setError('public_key', { message: 'Invalid key or failed to add.' }); + }, + }); + + const deleteKey = $api.useMutation('delete', '/api/settings/factors/pgp/delete/{fingerprint}', { + onSuccess: () => { setDeleteDialogOpen(false); onRefetch(); }, + }); + + const isEnabled = pgp.length > 0; + + const onSubmit = useCallback((data: PgpForm) => { + enable.mutate({ body: data }); + }, [enable]); + + const handleToggle = useCallback(() => { + setOpen(v => !v); + }, []); + + const handleDelete = () => { + if (!deleteTarget) return; + deleteKey.mutate({ params: { path: { fingerprint: deleteTarget.fingerprint } } }); + }; + + return ( + <> + } + name="PGP Key" + description="Authenticate by signing a server challenge with your PGP private key." + tag={{ label: isEnabled ? `${pgp.length} key${pgp.length > 1 ? 's' : ''}` : 'Disabled', enabled: isEnabled }} + onToggle={handleToggle} + open={open} + > + +
+ {isEnabled && ( +
+ {pgp.map(key => ( + } + name={key.display_name} + subtitle={key.fingerprint} + onRemove={() => { setDeleteTarget(key); setDeleteDialogOpen(true); }} + /> + ))} +
+ )} + +
+
+
+ + {/* Add PGP key modal */} + { if (!v) form.reset(); setAddOpen(v); }}> + + + Add PGP key + + Provide a name and your ASCII-armored public key. + + +
+ + ( + + + + + + + + )} /> + ( + + + +