handoff-app 2.0.0-2 → 2.0.0-3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -83
- package/dist/app-builder/build.js +21 -4
- package/dist/app-builder/client-config.js +9 -8
- package/dist/cli/auth/browser.d.ts +2 -0
- package/dist/cli/auth/browser.js +33 -0
- package/dist/cli/auth/device.d.ts +11 -0
- package/dist/cli/auth/device.js +155 -0
- package/dist/cli/auth/store.d.ts +29 -0
- package/dist/cli/auth/store.js +146 -0
- package/dist/cli/scaffold.js +20 -154
- package/dist/commands/index.js +4 -0
- package/dist/commands/login/index.d.ts +8 -0
- package/dist/commands/login/index.js +53 -0
- package/dist/commands/logout/index.d.ts +7 -0
- package/dist/commands/logout/index.js +54 -0
- package/dist/config/entries.d.ts +47 -0
- package/dist/config/entries.js +162 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +5 -1
- package/dist/config/runtime.d.ts +9 -9
- package/dist/config/runtime.js +15 -15
- package/dist/registry/auth/access-tokens.d.ts +46 -0
- package/dist/registry/auth/access-tokens.js +165 -0
- package/dist/registry/auth/action-tokens.d.ts +66 -0
- package/dist/registry/auth/action-tokens.js +178 -0
- package/dist/registry/auth/crypto.d.ts +4 -0
- package/dist/registry/auth/crypto.js +16 -0
- package/dist/registry/auth/database.d.ts +7 -0
- package/dist/registry/auth/database.js +22 -0
- package/dist/registry/auth/device-authorizations.d.ts +51 -0
- package/dist/registry/auth/device-authorizations.js +170 -0
- package/dist/registry/auth/gravatar.d.ts +1 -0
- package/dist/registry/auth/gravatar.js +8 -0
- package/dist/registry/auth/index.d.ts +11 -0
- package/dist/registry/auth/index.js +72 -0
- package/dist/registry/auth/installation.d.ts +42 -0
- package/dist/registry/auth/installation.js +105 -0
- package/dist/registry/auth/password.d.ts +12 -0
- package/dist/registry/auth/password.js +58 -0
- package/dist/registry/auth/rate-limits.d.ts +22 -0
- package/dist/registry/auth/rate-limits.js +75 -0
- package/dist/registry/auth/types.d.ts +26 -0
- package/dist/registry/auth/types.js +9 -0
- package/dist/registry/auth/user-record.d.ts +13 -0
- package/dist/registry/auth/user-record.js +16 -0
- package/dist/registry/auth/users.d.ts +31 -0
- package/dist/registry/auth/users.js +135 -0
- package/dist/registry/auth/validation.d.ts +3 -0
- package/dist/registry/auth/validation.js +28 -0
- package/dist/registry/checkout/assets.js +4 -2
- package/dist/registry/checkout/index.d.ts +1 -1
- package/dist/registry/checkout/index.js +101 -23
- package/dist/registry/checkout/tokens.js +4 -2
- package/dist/registry/connection.d.ts +9 -0
- package/dist/registry/connection.js +29 -1
- package/dist/registry/db/driver.d.ts +5 -4
- package/dist/registry/db/driver.js +5 -4
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +7 -1
- package/dist/registry/db/schema.d.ts +2521 -750
- package/dist/registry/db/schema.js +126 -1
- package/dist/registry/publish/assets.js +1 -1
- package/dist/registry/publish/errors.js +3 -3
- package/dist/registry/publish/index.d.ts +1 -1
- package/dist/registry/publish/index.js +9 -9
- package/dist/registry/publish/tokens.js +1 -1
- package/dist/types/config.d.ts +4 -1
- package/docs/cli.md +4 -0
- package/drizzle/0005_add_registry_auth.sql +95 -0
- package/drizzle/meta/0005_snapshot.json +1926 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/app/components/Auth/AccountLayout.tsx +87 -0
- package/src/app/components/Auth/AuthControls.tsx +99 -0
- package/src/app/components/Auth/AuthShell.tsx +73 -0
- package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
- package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
- package/src/app/components/Auth/api.ts +20 -0
- package/src/app/components/Layout/Header.tsx +2 -0
- package/src/app/components/Navigation/MobileNav.tsx +2 -0
- package/src/app/lib/auth/api.ts +113 -0
- package/src/app/lib/auth/config.ts +175 -0
- package/src/app/lib/auth/email.ts +34 -0
- package/src/app/lib/docs-api/runtime-config.ts +0 -14
- package/src/app/lib/registry-api/auth.ts +29 -46
- package/src/app/lib/registry-api/errors.ts +2 -2
- package/src/app/lib/registry-api/handler.ts +19 -14
- package/src/app/lib/registry-api/token-transfer.ts +1 -1
- package/src/app/lib/registry-api/transfer.ts +4 -4
- package/src/app/next.config.mjs +12 -3
- package/src/app/pages/_app.tsx +10 -4
- package/src/app/pages/account/index.tsx +153 -0
- package/src/app/pages/account/tokens.tsx +230 -0
- package/src/app/pages/account/users.tsx +310 -0
- package/src/app/pages/api/account/index.ts +26 -0
- package/src/app/pages/api/account/tokens/[id].ts +17 -0
- package/src/app/pages/api/account/tokens/index.ts +44 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
- package/src/app/pages/api/admin/users/index.ts +53 -0
- package/src/app/pages/api/auth/[...nextauth].ts +4 -0
- package/src/app/pages/api/auth/request-reset.ts +53 -0
- package/src/app/pages/api/auth/reset-password.ts +29 -0
- package/src/app/pages/api/install.ts +102 -0
- package/src/app/pages/api/oauth/device/approve.ts +23 -0
- package/src/app/pages/api/oauth/device/index.ts +41 -0
- package/src/app/pages/api/oauth/revoke.ts +24 -0
- package/src/app/pages/api/oauth/token.ts +31 -0
- package/src/app/pages/api/registry/health.ts +4 -3
- package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
- package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
- package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
- package/src/app/pages/cli/device.tsx +143 -0
- package/src/app/pages/install.tsx +336 -0
- package/src/app/pages/login.tsx +117 -0
- package/src/app/pages/reset-password.tsx +159 -0
- package/src/app/types/next-auth.d.ts +27 -0
- package/src/app-builder/build.ts +22 -5
- package/src/app-builder/client-config.ts +10 -9
- package/src/cli/auth/browser.ts +29 -0
- package/src/cli/auth/device.ts +181 -0
- package/src/cli/auth/store.ts +137 -0
- package/src/cli/scaffold.ts +20 -197
- package/src/commands/index.ts +4 -0
- package/src/commands/login/index.ts +48 -0
- package/src/commands/logout/index.ts +51 -0
- package/src/config/entries.ts +175 -0
- package/src/config/index.ts +4 -0
- package/src/config/runtime.ts +15 -14
- package/src/registry/auth/access-tokens.ts +198 -0
- package/src/registry/auth/action-tokens.ts +225 -0
- package/src/registry/auth/crypto.ts +13 -0
- package/src/registry/auth/database.ts +15 -0
- package/src/registry/auth/device-authorizations.ts +223 -0
- package/src/registry/auth/gravatar.ts +6 -0
- package/src/registry/auth/index.ts +89 -0
- package/src/registry/auth/installation.ts +117 -0
- package/src/registry/auth/password.ts +46 -0
- package/src/registry/auth/rate-limits.ts +84 -0
- package/src/registry/auth/types.ts +34 -0
- package/src/registry/auth/user-record.ts +25 -0
- package/src/registry/auth/users.ts +145 -0
- package/src/registry/auth/validation.ts +24 -0
- package/src/registry/checkout/assets.ts +4 -2
- package/src/registry/checkout/index.ts +114 -24
- package/src/registry/checkout/tokens.ts +4 -2
- package/src/registry/connection.ts +23 -0
- package/src/registry/db/driver.ts +5 -4
- package/src/registry/db/index.ts +12 -0
- package/src/registry/db/schema.ts +174 -1
- package/src/registry/publish/assets.ts +1 -1
- package/src/registry/publish/errors.ts +3 -3
- package/src/registry/publish/index.ts +9 -9
- package/src/registry/publish/tokens.ts +1 -1
- package/src/types/config.ts +4 -1
- package/templates/default/README.md.tpl +18 -0
- package/templates/default/gitignore.tpl +1 -0
- package/templates/default/handoff.config.js.tpl +0 -1
- package/templates/default/handoff.config.ts.tpl +0 -1
- package/templates/default/handoff.config.with-examples.js.tpl +0 -1
- package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { AccountLayout } from '../../components/Auth/AccountLayout';
|
|
2
|
+
import { authApiUrl, readApiError } from '../../components/Auth/api';
|
|
3
|
+
import { Alert, AlertDescription } from '../../components/ui/alert';
|
|
4
|
+
import { Badge } from '../../components/ui/badge';
|
|
5
|
+
import { Button } from '../../components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
|
7
|
+
import { Input } from '../../components/ui/input';
|
|
8
|
+
import { Label } from '../../components/ui/label';
|
|
9
|
+
import { useSession } from 'next-auth/react';
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
interface AccountUser {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
name?: string | null;
|
|
16
|
+
avatarUrl?: string | null;
|
|
17
|
+
image?: string | null;
|
|
18
|
+
role: 'admin' | 'member';
|
|
19
|
+
status?: 'invited' | 'active' | 'deactivated';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function AccountPage() {
|
|
23
|
+
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
24
|
+
return <AccountLayout title="Profile">{null}</AccountLayout>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return <RegistryAccountPage />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function RegistryAccountPage() {
|
|
31
|
+
const { data: session } = useSession();
|
|
32
|
+
const [user, setUser] = useState<AccountUser | null>(null);
|
|
33
|
+
const [name, setName] = useState('');
|
|
34
|
+
const [avatarUrl, setAvatarUrl] = useState('');
|
|
35
|
+
const [gravatarUrl, setGravatarUrl] = useState('');
|
|
36
|
+
const [loading, setLoading] = useState(true);
|
|
37
|
+
const [saving, setSaving] = useState(false);
|
|
38
|
+
const [message, setMessage] = useState<{ text: string; error?: boolean } | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!session?.user) return;
|
|
42
|
+
void fetch(authApiUrl('/api/account'), { credentials: 'include', cache: 'no-store' })
|
|
43
|
+
.then(async (response) => {
|
|
44
|
+
if (!response.ok) throw new Error(await readApiError(response, 'Could not load your account.'));
|
|
45
|
+
const body = (await response.json()) as AccountUser | { user: AccountUser; gravatarUrl?: string };
|
|
46
|
+
const account = 'user' in body ? body.user : body;
|
|
47
|
+
setUser(account);
|
|
48
|
+
setName(account.name ?? '');
|
|
49
|
+
setAvatarUrl(account.avatarUrl ?? account.image ?? '');
|
|
50
|
+
if ('gravatarUrl' in body && body.gravatarUrl) setGravatarUrl(body.gravatarUrl);
|
|
51
|
+
})
|
|
52
|
+
.catch((error: Error) => setMessage({ text: error.message, error: true }))
|
|
53
|
+
.finally(() => setLoading(false));
|
|
54
|
+
}, [session]);
|
|
55
|
+
|
|
56
|
+
const save = async () => {
|
|
57
|
+
setSaving(true);
|
|
58
|
+
setMessage(null);
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(authApiUrl('/api/account'), {
|
|
61
|
+
method: 'PUT',
|
|
62
|
+
credentials: 'include',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ name: name.trim(), avatarUrl: avatarUrl.trim() }),
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
setMessage({ text: await readApiError(response, 'Could not save your profile.'), error: true });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setMessage({ text: 'Profile saved.' });
|
|
71
|
+
} catch {
|
|
72
|
+
setMessage({ text: 'Could not connect to the registry.', error: true });
|
|
73
|
+
} finally {
|
|
74
|
+
setSaving(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const initials = (name || user?.email || 'U')
|
|
79
|
+
.split(/\s+/)
|
|
80
|
+
.map((part) => part[0])
|
|
81
|
+
.join('')
|
|
82
|
+
.slice(0, 2)
|
|
83
|
+
.toUpperCase();
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<AccountLayout title="Profile">
|
|
87
|
+
<Card>
|
|
88
|
+
<CardHeader>
|
|
89
|
+
<CardTitle>Profile</CardTitle>
|
|
90
|
+
<CardDescription>Manage how your account appears in this registry.</CardDescription>
|
|
91
|
+
</CardHeader>
|
|
92
|
+
<CardContent className="space-y-5">
|
|
93
|
+
{message ? (
|
|
94
|
+
<Alert variant={message.error ? 'destructive' : 'green'}>
|
|
95
|
+
<AlertDescription>{message.text}</AlertDescription>
|
|
96
|
+
</Alert>
|
|
97
|
+
) : null}
|
|
98
|
+
{loading ? (
|
|
99
|
+
<p className="text-sm text-muted-foreground">Loading profile…</p>
|
|
100
|
+
) : user ? (
|
|
101
|
+
<>
|
|
102
|
+
<div className="flex items-center gap-4">
|
|
103
|
+
{avatarUrl ? (
|
|
104
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
105
|
+
<img src={avatarUrl} alt="" className="h-16 w-16 rounded-full border object-cover" />
|
|
106
|
+
) : (
|
|
107
|
+
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-semibold">{initials}</div>
|
|
108
|
+
)}
|
|
109
|
+
<div>
|
|
110
|
+
<p className="font-medium">{name || user.email}</p>
|
|
111
|
+
<Badge variant={user.status === 'deactivated' ? 'destructive' : 'secondary'} className="mt-1">
|
|
112
|
+
{user.role}
|
|
113
|
+
</Badge>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="space-y-2">
|
|
117
|
+
<Label htmlFor="account-email">Email</Label>
|
|
118
|
+
<Input id="account-email" value={user.email} disabled />
|
|
119
|
+
<p className="text-xs text-muted-foreground">Email addresses cannot be changed.</p>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="space-y-2">
|
|
122
|
+
<Label htmlFor="account-name">Display name</Label>
|
|
123
|
+
<Input id="account-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={100} />
|
|
124
|
+
</div>
|
|
125
|
+
<div className="space-y-2">
|
|
126
|
+
<div className="flex items-center justify-between gap-2">
|
|
127
|
+
<Label htmlFor="account-avatar">Avatar URL</Label>
|
|
128
|
+
{gravatarUrl ? (
|
|
129
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => setAvatarUrl(gravatarUrl)}>
|
|
130
|
+
Use Gravatar
|
|
131
|
+
</Button>
|
|
132
|
+
) : null}
|
|
133
|
+
</div>
|
|
134
|
+
<Input
|
|
135
|
+
id="account-avatar"
|
|
136
|
+
type="url"
|
|
137
|
+
placeholder="https://example.com/avatar.png"
|
|
138
|
+
value={avatarUrl}
|
|
139
|
+
onChange={(event) => setAvatarUrl(event.target.value)}
|
|
140
|
+
maxLength={500}
|
|
141
|
+
/>
|
|
142
|
+
<p className="text-xs text-muted-foreground">Use an HTTPS image URL, click Use Gravatar, or leave this empty to show your initials.</p>
|
|
143
|
+
</div>
|
|
144
|
+
<Button onClick={() => void save()} disabled={saving}>
|
|
145
|
+
{saving ? 'Saving…' : 'Save changes'}
|
|
146
|
+
</Button>
|
|
147
|
+
</>
|
|
148
|
+
) : null}
|
|
149
|
+
</CardContent>
|
|
150
|
+
</Card>
|
|
151
|
+
</AccountLayout>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { AccountLayout } from '../../components/Auth/AccountLayout';
|
|
2
|
+
import { authApiUrl, readApiError } from '../../components/Auth/api';
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle } from '../../components/ui/alert';
|
|
4
|
+
import { Badge } from '../../components/ui/badge';
|
|
5
|
+
import { Button } from '../../components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../../components/ui/card';
|
|
7
|
+
import { Input } from '../../components/ui/input';
|
|
8
|
+
import { Label } from '../../components/ui/label';
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
|
10
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
|
|
11
|
+
import { Check, Copy, KeyRound, Trash2 } from 'lucide-react';
|
|
12
|
+
import { useSession } from 'next-auth/react';
|
|
13
|
+
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
interface AccessToken {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
prefix?: string;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
createdAt: string;
|
|
21
|
+
expiresAt?: string | null;
|
|
22
|
+
lastUsedAt?: string | null;
|
|
23
|
+
revokedAt?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const formatDate = (value?: string | null) => {
|
|
27
|
+
if (!value) return 'Never';
|
|
28
|
+
const date = new Date(value);
|
|
29
|
+
return Number.isNaN(date.valueOf()) ? value : date.toLocaleDateString();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default function TokensPage() {
|
|
33
|
+
if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
|
|
34
|
+
return <AccountLayout title="Access tokens">{null}</AccountLayout>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return <RegistryTokensPage />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function RegistryTokensPage() {
|
|
41
|
+
const { data: session } = useSession();
|
|
42
|
+
const role = (session?.user as { role?: string } | undefined)?.role;
|
|
43
|
+
const [tokens, setTokens] = useState<AccessToken[]>([]);
|
|
44
|
+
const [scope, setScope] = useState<'registry:read' | 'registry:write'>('registry:read');
|
|
45
|
+
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
|
46
|
+
const [copied, setCopied] = useState(false);
|
|
47
|
+
const [pending, setPending] = useState(false);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
|
|
50
|
+
const load = useCallback(async () => {
|
|
51
|
+
const response = await fetch(authApiUrl('/api/account/tokens'), { credentials: 'include', cache: 'no-store' });
|
|
52
|
+
if (!response.ok) throw new Error(await readApiError(response, 'Could not load access tokens.'));
|
|
53
|
+
const body = (await response.json()) as AccessToken[] | { tokens: AccessToken[] };
|
|
54
|
+
setTokens(Array.isArray(body) ? body : body.tokens);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (process.env.HANDOFF_RUNTIME_MODE === 'registry' && session?.user) {
|
|
59
|
+
void load().catch((reason: Error) => setError(reason.message));
|
|
60
|
+
}
|
|
61
|
+
}, [load, session]);
|
|
62
|
+
|
|
63
|
+
const create = async (event: FormEvent<HTMLFormElement>) => {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
setPending(true);
|
|
66
|
+
setError(null);
|
|
67
|
+
setCreatedToken(null);
|
|
68
|
+
const data = new FormData(event.currentTarget);
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(authApiUrl('/api/account/tokens'), {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
credentials: 'include',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ name: String(data.get('name') ?? '').trim(), scopes: [scope] }),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
setError(await readApiError(response, 'Could not create the access token.'));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const body = (await response.json()) as { token?: string; accessToken?: string };
|
|
81
|
+
setCreatedToken(body.token ?? body.accessToken ?? null);
|
|
82
|
+
event.currentTarget.reset();
|
|
83
|
+
await load();
|
|
84
|
+
} catch {
|
|
85
|
+
setError('Could not connect to the registry.');
|
|
86
|
+
} finally {
|
|
87
|
+
setPending(false);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const revoke = async (token: AccessToken) => {
|
|
92
|
+
if (!window.confirm(`Revoke “${token.name}”? Applications using it will immediately lose access.`)) return;
|
|
93
|
+
setPending(true);
|
|
94
|
+
setError(null);
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(authApiUrl(`/api/account/tokens/${encodeURIComponent(token.id)}`), {
|
|
97
|
+
method: 'DELETE',
|
|
98
|
+
credentials: 'include',
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
setError(await readApiError(response, 'Could not revoke the token.'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await load();
|
|
105
|
+
} finally {
|
|
106
|
+
setPending(false);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const copyToken = async () => {
|
|
111
|
+
if (!createdToken) return;
|
|
112
|
+
await navigator.clipboard.writeText(createdToken);
|
|
113
|
+
setCopied(true);
|
|
114
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<AccountLayout title="Access tokens">
|
|
119
|
+
<div className="space-y-6">
|
|
120
|
+
<Card>
|
|
121
|
+
<CardHeader>
|
|
122
|
+
<CardTitle>Create access token</CardTitle>
|
|
123
|
+
<CardDescription>Use tokens for CI or integrations that cannot complete the browser device login.</CardDescription>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<form onSubmit={create}>
|
|
126
|
+
<CardContent className="space-y-4">
|
|
127
|
+
{error ? (
|
|
128
|
+
<Alert variant="destructive">
|
|
129
|
+
<AlertDescription>{error}</AlertDescription>
|
|
130
|
+
</Alert>
|
|
131
|
+
) : null}
|
|
132
|
+
{createdToken ? (
|
|
133
|
+
<Alert variant="warning">
|
|
134
|
+
<KeyRound />
|
|
135
|
+
<AlertTitle>Copy this token now</AlertTitle>
|
|
136
|
+
<AlertDescription>
|
|
137
|
+
<p className="mb-3">For security, it will not be shown again.</p>
|
|
138
|
+
<div className="flex gap-2">
|
|
139
|
+
<Input className="font-mono text-xs" readOnly value={createdToken} />
|
|
140
|
+
<Button type="button" size="icon" variant="outline" onClick={() => void copyToken()} aria-label="Copy token">
|
|
141
|
+
{copied ? <Check /> : <Copy />}
|
|
142
|
+
</Button>
|
|
143
|
+
</div>
|
|
144
|
+
</AlertDescription>
|
|
145
|
+
</Alert>
|
|
146
|
+
) : null}
|
|
147
|
+
<div className="grid gap-4 sm:grid-cols-[2fr_1fr]">
|
|
148
|
+
<div className="space-y-2">
|
|
149
|
+
<Label htmlFor="token-name">Name</Label>
|
|
150
|
+
<Input id="token-name" name="name" required maxLength={100} placeholder="Production CI" />
|
|
151
|
+
</div>
|
|
152
|
+
<div className="space-y-2">
|
|
153
|
+
<Label htmlFor="token-scope">Access</Label>
|
|
154
|
+
<Select value={scope} onValueChange={(value) => setScope(value as typeof scope)}>
|
|
155
|
+
<SelectTrigger id="token-scope">
|
|
156
|
+
<SelectValue />
|
|
157
|
+
</SelectTrigger>
|
|
158
|
+
<SelectContent>
|
|
159
|
+
<SelectItem value="registry:read">Read only</SelectItem>
|
|
160
|
+
{role === 'admin' ? <SelectItem value="registry:write">Read and write</SelectItem> : null}
|
|
161
|
+
</SelectContent>
|
|
162
|
+
</Select>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
<p className="text-xs text-muted-foreground">Tokens expire after one year unless revoked earlier.</p>
|
|
166
|
+
</CardContent>
|
|
167
|
+
<CardFooter className="justify-end border-t px-6 py-4">
|
|
168
|
+
<Button type="submit" disabled={pending}>
|
|
169
|
+
Create
|
|
170
|
+
</Button>
|
|
171
|
+
</CardFooter>
|
|
172
|
+
</form>
|
|
173
|
+
</Card>
|
|
174
|
+
|
|
175
|
+
<Card>
|
|
176
|
+
<CardHeader>
|
|
177
|
+
<CardTitle>Access tokens</CardTitle>
|
|
178
|
+
<CardDescription>Review and revoke credentials issued for your account.</CardDescription>
|
|
179
|
+
</CardHeader>
|
|
180
|
+
<CardContent>
|
|
181
|
+
<Table>
|
|
182
|
+
<TableHeader>
|
|
183
|
+
<TableRow>
|
|
184
|
+
<TableHead>Name</TableHead>
|
|
185
|
+
<TableHead>Access</TableHead>
|
|
186
|
+
<TableHead>Last used</TableHead>
|
|
187
|
+
<TableHead>Expires</TableHead>
|
|
188
|
+
<TableHead className="text-right">Action</TableHead>
|
|
189
|
+
</TableRow>
|
|
190
|
+
</TableHeader>
|
|
191
|
+
<TableBody>
|
|
192
|
+
{tokens.length ? (
|
|
193
|
+
tokens.map((token) => (
|
|
194
|
+
<TableRow key={token.id}>
|
|
195
|
+
<TableCell>
|
|
196
|
+
<p className="font-medium">{token.name}</p>
|
|
197
|
+
{token.prefix ? <p className="font-mono text-xs text-muted-foreground">{token.prefix}…</p> : null}
|
|
198
|
+
</TableCell>
|
|
199
|
+
<TableCell className="space-x-1">
|
|
200
|
+
{token.scopes.map((item) => (
|
|
201
|
+
<Badge key={item} variant="secondary">
|
|
202
|
+
{item === 'registry:write' ? 'Read/write' : 'Read'}
|
|
203
|
+
</Badge>
|
|
204
|
+
))}
|
|
205
|
+
{token.revokedAt ? <Badge variant="destructive">Revoked</Badge> : null}
|
|
206
|
+
</TableCell>
|
|
207
|
+
<TableCell>{formatDate(token.lastUsedAt)}</TableCell>
|
|
208
|
+
<TableCell>{formatDate(token.expiresAt)}</TableCell>
|
|
209
|
+
<TableCell className="text-right">
|
|
210
|
+
<Button size="sm" variant="ghost" disabled={pending || Boolean(token.revokedAt)} onClick={() => void revoke(token)}>
|
|
211
|
+
<Trash2 /> Revoke
|
|
212
|
+
</Button>
|
|
213
|
+
</TableCell>
|
|
214
|
+
</TableRow>
|
|
215
|
+
))
|
|
216
|
+
) : (
|
|
217
|
+
<TableRow>
|
|
218
|
+
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
|
219
|
+
No active access tokens.
|
|
220
|
+
</TableCell>
|
|
221
|
+
</TableRow>
|
|
222
|
+
)}
|
|
223
|
+
</TableBody>
|
|
224
|
+
</Table>
|
|
225
|
+
</CardContent>
|
|
226
|
+
</Card>
|
|
227
|
+
</div>
|
|
228
|
+
</AccountLayout>
|
|
229
|
+
);
|
|
230
|
+
}
|