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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/frontend/app/(loginform)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ import { AnimatePresence, motion } from 'motion/react';
import { RecoveryCode } from './recovery-code';
import { WebAuthn } from './webauthn';
import { WebAuthnPasswordless } from './webauthn-passwordless';
import { Pgp } from './pgp';

export const formSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().optional(),
totp: z.string().optional(),
recovery_code: z.string().optional(),
pgp_signature: z.string().optional(),
});

export type FormSchema = z.infer<typeof formSchema>;
Expand Down Expand Up @@ -48,6 +50,7 @@ export default function Page() {
password: '',
totp: '',
recovery_code: '',
pgp_signature: '',
},
});

Expand All @@ -69,6 +72,7 @@ export default function Page() {
{screen === 'recoverycode' && <RecoveryCode />}
{screen === 'webauthn' && <WebAuthn />}
{screen === 'webauthnpasswordless' && <WebAuthnPasswordless />}
{screen === 'pgp' && <Pgp />}
</motion.div>
</AnimatePresence>
<div className="text-muted-foreground text-xs absolute left-4 right-4 bottom-4 text-center">
Expand Down
114 changes: 114 additions & 0 deletions apps/frontend/app/(loginform)/login/pgp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client';

import { Alert, AlertDescription, AlertTitle } from '@components/ui/alert';
import { Button } from '@components/ui/button';
import { LinkComponent } from '@components/ui/link';
import { LoginIcon } from '@components/ui/login-icon';
import { $api } from '@lib/providers/api';
import { IconAlertCircle, IconArrowRight, IconKey } from '@tabler/icons-react';
import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useLoginSuccess } from '@lib/hooks';
import { FormSchema, screenAtom } from './page';
import { ChallengeStep } from './pgp/challenge-step';
import { QuickSignCommand } from './pgp/quick-sign-command';
import { SignatureStep } from './pgp/signature-step';

export function Pgp() {
const setScreen = useSetAtom(screenAtom);
const form = useFormContext<FormSchema>();
const username = form.watch('username');
const { onSuccess } = useLoginSuccess();

const [refreshSpin, setRefreshSpin] = useState(0);

const challengeQuery = $api.useQuery(
'get',
'/api/login/pgp/challenge',
{},
{
retry: false,
refetchOnWindowFocus: false,
}
);

const challenge = challengeQuery.data?.challenge ?? '';
const challengeError = challengeQuery.isError
? 'Failed to generate challenge. Try again in a moment.'
: '';

const pgpLogin = $api.useMutation('post', '/api/login/pgp/challenge', {
onSuccess,
onError: () => {
form.setError('pgp_signature', {
message: 'Login failed.',
});
},
});

const refreshChallenge = useCallback(() => {
form.clearErrors('pgp_signature');
challengeQuery.refetch();
setRefreshSpin((s) => s + 1);
}, [form, challengeQuery]);

const gpgCommand = challenge ? `echo "${challenge}" | gpg --clearsign` : '';

return (
<form
className="flex flex-col items-center"
onSubmit={form.handleSubmit((data) =>
pgpLogin.mutate({
body: {
username: data.username,
signature: data.pgp_signature ?? '',
},
})
)}
>
<LoginIcon>
<IconKey />
</LoginIcon>
<div className="mt-4 flex flex-col gap-1">
<h1 className="font-semibold text-xl text-center">Sign in with a PGP key</h1>
<p className="text-sm text-center text-muted-foreground">
Sign the server challenge for {username}{' '}
<LinkComponent onClick={() => setScreen('welcome')}>Not you?</LinkComponent>
</p>
</div>
<div className="w-sm mt-6 flex flex-col gap-3">
{challengeError && (
<Alert variant="destructive">
<IconAlertCircle />
<AlertTitle>Challenge Unavailable</AlertTitle>
<AlertDescription>{challengeError}</AlertDescription>
</Alert>
)}

<ChallengeStep
challenge={challenge}
refreshChallenge={refreshChallenge}
isPending={challengeQuery.isFetching}
refreshSpin={refreshSpin}
/>

{challenge && <QuickSignCommand gpgCommand={gpgCommand} />}

<SignatureStep />

<Button
type="submit"
disabled={!challenge || challengeQuery.isFetching || pgpLogin.isPending}
>
Next <IconArrowRight />
</Button>
<div className="text-muted-foreground text-center text-sm">
<LinkComponent>
<div onClick={() => setScreen('login-options')}>More Options</div>
</LinkComponent>
</div>
</div>
</form>
);
}
44 changes: 44 additions & 0 deletions apps/frontend/app/(loginform)/login/pgp/challenge-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { IconRefresh } from '@tabler/icons-react';
import { motion } from 'motion/react';
import { CopyButton } from '@components/copy-button';

interface ChallengeStepProps {
challenge: string;
refreshChallenge: () => void;
isPending: boolean;
refreshSpin: number;
}

export function ChallengeStep({ challenge, refreshChallenge, isPending, refreshSpin }: ChallengeStepProps) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">
<span className="text-muted-foreground mr-1.5">1.</span>
Copy the challenge
</label>
<div className="flex items-center gap-2">
<CopyButton text={challenge} compact />
<button
type="button"
onClick={refreshChallenge}
disabled={isPending}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
<motion.span
key={refreshSpin}
animate={{ rotate: -360 }}
transition={{ type: 'spring', stiffness: 200, damping: 15 }}
className="inline-flex"
>
<IconRefresh className="size-3.5" />
</motion.span>
</button>
</div>
</div>
<div className="rounded-md border border-input dark:bg-input/30 bg-transparent px-3 py-2.5 font-mono text-xs break-all select-all cursor-text">
{challenge || <span className="text-muted-foreground">Generating...</span>}
</div>
</div>
);
}
50 changes: 50 additions & 0 deletions apps/frontend/app/(loginform)/login/pgp/quick-sign-command.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { IconTerminal2, IconChevronDown } from '@tabler/icons-react';
import { AnimatePresence, motion } from 'motion/react';
import { CopyButton } from '@components/copy-button';

interface QuickSignCommandProps {
gpgCommand: string;
}

export function QuickSignCommand({ gpgCommand }: QuickSignCommandProps) {
const [showCommand, setShowCommand] = useState(false);

return (
<div>
<button
type="button"
onClick={() => setShowCommand(!showCommand)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<IconTerminal2 className="size-3.5" />
<span>Quick sign command</span>
<motion.span
animate={{ rotate: showCommand ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="inline-flex"
>
<IconChevronDown className="size-3" />
</motion.span>
</button>
<AnimatePresence initial={false}>
{showCommand && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="mt-1.5 rounded-md border border-input dark:bg-input/30 bg-transparent px-3 py-2.5 flex items-start justify-between gap-2">
<pre className="font-mono text-[11px] text-muted-foreground whitespace-pre-wrap break-all select-all flex-1">
{gpgCommand}
</pre>
<CopyButton text={gpgCommand} compact />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
33 changes: 33 additions & 0 deletions apps/frontend/app/(loginform)/login/pgp/signature-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { FormControl, FormField, FormItem, FormMessage } from '@components/ui/form';
import { useFormContext } from 'react-hook-form';
import { FormSchema } from '../page';

export function SignatureStep() {
const form = useFormContext<FormSchema>();

return (
<div className="space-y-1.5">
<label className="text-sm font-medium">
<span className="text-muted-foreground mr-1.5">2.</span>
Paste the signed message
</label>
<FormField
control={form.control}
name="pgp_signature"
render={({ field }) => (
<FormItem>
<FormControl>
<textarea
{...field}
autoFocus
placeholder="-----BEGIN PGP SIGNED MESSAGE-----"
className="min-h-28 w-full resize-none rounded-md border border-input dark:bg-input/30 bg-transparent px-3 py-2.5 font-mono text-xs placeholder:text-muted-foreground shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
}
56 changes: 51 additions & 5 deletions apps/frontend/app/dashboard/components/dashboard-status.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,58 @@
function SkeletonCard({ rows }: { rows: number }) {
return (
<div className="rounded-2xl border border-border bg-card">
{[...Array(rows)].map((_, i) => (
<div key={i} className={`px-5 py-4 flex items-center gap-4 ${i < rows - 1 ? 'border-b border-border/60' : ''}`}>
<div className="size-5 rounded bg-muted animate-pulse shrink-0" />
<div className="flex-1 min-w-0">
<div className="h-[19px] w-28 rounded bg-muted animate-pulse mb-0.5" />
<div className="h-4 w-40 rounded bg-muted animate-pulse" />
</div>
</div>
))}
</div>
);
}

export function DashboardSkeleton() {
return (
<>
<div className="mb-6">
<div className="h-5 w-12 rounded bg-muted animate-pulse mb-3" />
<SkeletonCard rows={1} />
</div>
<div className="mb-6">
<div className="h-5 w-16 rounded bg-muted animate-pulse mb-3" />
<SkeletonCard rows={5} />
</div>
<div className="mt-8">
<div className="h-5 w-24 rounded bg-muted/60 animate-pulse mb-3" />
<div className="rounded-2xl border border-destructive/20 bg-card">
<div className="px-5 py-4 flex items-center justify-between">
<div>
<div className="h-5 w-28 rounded bg-muted animate-pulse" />
<div className="h-4 w-48 rounded bg-muted animate-pulse" />
</div>
<div className="h-8 w-[83px] rounded-md bg-muted animate-pulse shrink-0" />
</div>
</div>
</div>
</>
);
}

export function SessionsSkeleton() {
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' : ''}`}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={`px-5 py-4 flex items-center gap-4 ${i < 2 ? '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 className="flex-1 min-w-0">
<div className="h-[19px] w-32 rounded bg-muted animate-pulse mb-0.5" />
<div className="h-4 w-48 rounded bg-muted animate-pulse" />
</div>
</div>
))}
Expand Down
26 changes: 2 additions & 24 deletions apps/frontend/app/dashboard/components/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,10 @@

import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { IconCheck, IconCopy, IconEye, IconEyeOff } from '@tabler/icons-react';
import { 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 (
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs text-muted-foreground"
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API unavailable (e.g. insecure context)
}
}}
>
{copied ? <IconCheck size={13} /> : <IconCopy size={13} />}
{copied ? 'Copied' : 'Copy'}
</Button>
);
}
export { CopyButton } from '@components/copy-button';

export function ErrorMsg({ msg }: { msg: string }) {
if (!msg) return null;
Expand Down
Loading
Loading