@owlmeans/web-auth-token 0.1.18-rc.1
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 +82 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/web-auth-token/SKILL.md +128 -0
- package/build/@/components/ui/alert.d.ts +10 -0
- package/build/@/components/ui/alert.d.ts.map +1 -0
- package/build/@/components/ui/alert.js +26 -0
- package/build/@/components/ui/alert.js.map +1 -0
- package/build/@/components/ui/badge.d.ts +10 -0
- package/build/@/components/ui/badge.d.ts.map +1 -0
- package/build/@/components/ui/badge.js +23 -0
- package/build/@/components/ui/badge.js.map +1 -0
- package/build/@/components/ui/button.d.ts +11 -0
- package/build/@/components/ui/button.d.ts.map +1 -0
- package/build/@/components/ui/button.js +32 -0
- package/build/@/components/ui/button.js.map +1 -0
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/dialog.d.ts +17 -0
- package/build/@/components/ui/dialog.d.ts.map +1 -0
- package/build/@/components/ui/dialog.js +36 -0
- package/build/@/components/ui/dialog.js.map +1 -0
- package/build/@/components/ui/input.d.ts +4 -0
- package/build/@/components/ui/input.d.ts.map +1 -0
- package/build/@/components/ui/input.js +7 -0
- package/build/@/components/ui/input.js.map +1 -0
- package/build/@/components/ui/label.d.ts +5 -0
- package/build/@/components/ui/label.d.ts.map +1 -0
- package/build/@/components/ui/label.js +8 -0
- package/build/@/components/ui/label.js.map +1 -0
- package/build/@/components/ui/select.d.ts +16 -0
- package/build/@/components/ui/select.d.ts.map +1 -0
- package/build/@/components/ui/select.js +38 -0
- package/build/@/components/ui/select.js.map +1 -0
- package/build/@/components/ui/table.d.ts +11 -0
- package/build/@/components/ui/table.d.ts.map +1 -0
- package/build/@/components/ui/table.js +28 -0
- package/build/@/components/ui/table.js.map +1 -0
- package/build/@/lib/utils.d.ts +3 -0
- package/build/@/lib/utils.d.ts.map +1 -0
- package/build/@/lib/utils.js +6 -0
- package/build/@/lib/utils.js.map +1 -0
- package/build/components/connected.d.ts +16 -0
- package/build/components/connected.d.ts.map +1 -0
- package/build/components/connected.js +14 -0
- package/build/components/connected.js.map +1 -0
- package/build/components/index.d.ts +3 -0
- package/build/components/index.d.ts.map +1 -0
- package/build/components/index.js +3 -0
- package/build/components/index.js.map +1 -0
- package/build/components/panel.d.ts +11 -0
- package/build/components/panel.d.ts.map +1 -0
- package/build/components/panel.js +119 -0
- package/build/components/panel.js.map +1 -0
- package/build/consts.d.ts +19 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +19 -0
- package/build/consts.js.map +1 -0
- package/build/helpers.d.ts +18 -0
- package/build/helpers.d.ts.map +1 -0
- package/build/helpers.js +45 -0
- package/build/helpers.js.map +1 -0
- package/build/hooks/use-access-tokens.d.ts +11 -0
- package/build/hooks/use-access-tokens.d.ts.map +1 -0
- package/build/hooks/use-access-tokens.js +74 -0
- package/build/hooks/use-access-tokens.js.map +1 -0
- package/build/i18n/be.json +51 -0
- package/build/i18n/de.json +51 -0
- package/build/i18n/en.json +51 -0
- package/build/i18n/es.json +51 -0
- package/build/i18n/pl.json +51 -0
- package/build/i18n/ru.json +51 -0
- package/build/i18n/uk.json +51 -0
- package/build/i18n.d.ts +2 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +17 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/build/types.d.ts +54 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils.d.ts +13 -0
- package/build/utils.d.ts.map +1 -0
- package/build/utils.js +14 -0
- package/build/utils.js.map +1 -0
- package/components.json +21 -0
- package/package.json +68 -0
- package/src/@/components/ui/alert.tsx +70 -0
- package/src/@/components/ui/badge.tsx +47 -0
- package/src/@/components/ui/button.tsx +60 -0
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/dialog.tsx +147 -0
- package/src/@/components/ui/input.tsx +22 -0
- package/src/@/components/ui/label.tsx +23 -0
- package/src/@/components/ui/select.tsx +184 -0
- package/src/@/components/ui/table.tsx +115 -0
- package/src/@/globals.css +57 -0
- package/src/@/lib/utils.ts +6 -0
- package/src/components/connected.tsx +34 -0
- package/src/components/index.ts +2 -0
- package/src/components/panel.tsx +310 -0
- package/src/consts.ts +21 -0
- package/src/helpers.ts +50 -0
- package/src/hooks/use-access-tokens.ts +83 -0
- package/src/i18n/be.json +51 -0
- package/src/i18n/de.json +51 -0
- package/src/i18n/en.json +51 -0
- package/src/i18n/es.json +51 -0
- package/src/i18n/pl.json +51 -0
- package/src/i18n/ru.json +51 -0
- package/src/i18n/uk.json +51 -0
- package/src/i18n.ts +18 -0
- package/src/index.ts +7 -0
- package/src/types.ts +59 -0
- package/src/utils.ts +14 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@source "../..";
|
|
4
|
+
|
|
5
|
+
@theme {
|
|
6
|
+
--color-background: oklch(1 0 0);
|
|
7
|
+
--color-foreground: oklch(0.145 0 0);
|
|
8
|
+
|
|
9
|
+
--color-card: oklch(1 0 0);
|
|
10
|
+
--color-card-foreground: oklch(0.145 0 0);
|
|
11
|
+
|
|
12
|
+
--color-popover: oklch(1 0 0);
|
|
13
|
+
--color-popover-foreground: oklch(0.145 0 0);
|
|
14
|
+
|
|
15
|
+
--color-primary: oklch(0.205 0 0);
|
|
16
|
+
--color-primary-foreground: oklch(0.985 0 0);
|
|
17
|
+
|
|
18
|
+
--color-secondary: oklch(0.97 0 0);
|
|
19
|
+
--color-secondary-foreground: oklch(0.205 0 0);
|
|
20
|
+
|
|
21
|
+
--color-muted: oklch(0.97 0 0);
|
|
22
|
+
--color-muted-foreground: oklch(0.556 0 0);
|
|
23
|
+
|
|
24
|
+
--color-accent: oklch(0.97 0 0);
|
|
25
|
+
--color-accent-foreground: oklch(0.205 0 0);
|
|
26
|
+
|
|
27
|
+
--color-destructive: oklch(0.577 0.245 27.325);
|
|
28
|
+
--color-success: oklch(0.62 0.18 145);
|
|
29
|
+
|
|
30
|
+
--color-border: oklch(0.922 0 0);
|
|
31
|
+
--color-input: oklch(0.922 0 0);
|
|
32
|
+
--color-ring: oklch(0.708 0 0);
|
|
33
|
+
|
|
34
|
+
--radius: 0.625rem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.dark {
|
|
38
|
+
--color-background: oklch(0.145 0 0);
|
|
39
|
+
--color-foreground: oklch(0.985 0 0);
|
|
40
|
+
--color-card: oklch(0.205 0 0);
|
|
41
|
+
--color-card-foreground: oklch(0.985 0 0);
|
|
42
|
+
--color-popover: oklch(0.205 0 0);
|
|
43
|
+
--color-popover-foreground: oklch(0.985 0 0);
|
|
44
|
+
--color-primary: oklch(0.922 0 0);
|
|
45
|
+
--color-primary-foreground: oklch(0.205 0 0);
|
|
46
|
+
--color-secondary: oklch(0.269 0 0);
|
|
47
|
+
--color-secondary-foreground: oklch(0.985 0 0);
|
|
48
|
+
--color-muted: oklch(0.269 0 0);
|
|
49
|
+
--color-muted-foreground: oklch(0.708 0 0);
|
|
50
|
+
--color-accent: oklch(0.269 0 0);
|
|
51
|
+
--color-accent-foreground: oklch(0.985 0 0);
|
|
52
|
+
--color-destructive: oklch(0.704 0.191 22.216);
|
|
53
|
+
--color-success: oklch(0.7 0.18 145);
|
|
54
|
+
--color-border: oklch(1 0 0 / 10%);
|
|
55
|
+
--color-input: oklch(1 0 0 / 15%);
|
|
56
|
+
--color-ring: oklch(0.556 0 0);
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FC } from 'react'
|
|
2
|
+
import { useAccessTokens } from '../hooks/use-access-tokens.js'
|
|
3
|
+
import { AccessTokensPanel } from './panel.js'
|
|
4
|
+
import type { AccessTokensAliases } from '../types.js'
|
|
5
|
+
|
|
6
|
+
export interface ConnectedAccessTokensPanelProps {
|
|
7
|
+
/** Override where the three routes are mounted. Defaults to the `authToken` alias tree. */
|
|
8
|
+
aliases?: AccessTokensAliases
|
|
9
|
+
/** Already-translated copy shown beside a freshly issued token — see `AccessTokensPanelProps`. */
|
|
10
|
+
usageHint?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The panel wired to the token entrypoints of the current context.
|
|
15
|
+
*
|
|
16
|
+
* This is the whole feature in one element, and the only piece that talks to a server. Anything
|
|
17
|
+
* that needs a different transport renders `AccessTokensPanel` against its own state instead.
|
|
18
|
+
*/
|
|
19
|
+
export const ConnectedAccessTokensPanel: FC<ConnectedAccessTokensPanelProps> = ({
|
|
20
|
+
aliases, usageHint
|
|
21
|
+
}) => {
|
|
22
|
+
const { items, issued, loading, error, create, revoke, dismissIssued } = useAccessTokens(aliases)
|
|
23
|
+
|
|
24
|
+
return <AccessTokensPanel
|
|
25
|
+
items={items}
|
|
26
|
+
issued={issued}
|
|
27
|
+
loading={loading}
|
|
28
|
+
error={error}
|
|
29
|
+
onCreate={create}
|
|
30
|
+
onRevoke={revoke}
|
|
31
|
+
onDismissIssued={dismissIssued}
|
|
32
|
+
usageHint={usageHint}
|
|
33
|
+
/>
|
|
34
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { useCallback, useId, useState } from 'react'
|
|
2
|
+
import type { FC, FormEvent } from 'react'
|
|
3
|
+
import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
|
|
4
|
+
import { AUTH_TOKEN_NAME_MAX } from '@owlmeans/auth-token'
|
|
5
|
+
import { Check, Copy, Loader2, Plus, Trash2 } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
8
|
+
import { Badge } from '@/components/ui/badge'
|
|
9
|
+
import { Button } from '@/components/ui/button'
|
|
10
|
+
import {
|
|
11
|
+
Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle
|
|
12
|
+
} from '@/components/ui/card'
|
|
13
|
+
import {
|
|
14
|
+
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle
|
|
15
|
+
} from '@/components/ui/dialog'
|
|
16
|
+
import { Input } from '@/components/ui/input'
|
|
17
|
+
import { Label } from '@/components/ui/label'
|
|
18
|
+
import {
|
|
19
|
+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
|
20
|
+
} from '@/components/ui/select'
|
|
21
|
+
import {
|
|
22
|
+
Table, TableBody, TableCell, TableHead, TableHeader, TableRow
|
|
23
|
+
} from '@/components/ui/table'
|
|
24
|
+
|
|
25
|
+
import { AUTH_TOKEN_I18N, DAY_SECONDS, TOKEN_EXPIRY_CHOICES, TOKEN_EXPIRY_NEVER } from '../consts.js'
|
|
26
|
+
import { formatMoment, tokenStatus } from '../helpers.js'
|
|
27
|
+
import type { AccessTokenStatus, AccessTokensPanelProps } from '../types.js'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A revoked token stays in the list so its name still resolves, and it must not read like a
|
|
31
|
+
* working one — so only `active` gets the filled badge, and the two dead states are quiet.
|
|
32
|
+
*/
|
|
33
|
+
const STATUS_VARIANT: Record<AccessTokenStatus, 'default' | 'secondary' | 'outline'> = {
|
|
34
|
+
active: 'default',
|
|
35
|
+
expired: 'secondary',
|
|
36
|
+
revoked: 'outline',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const COLUMNS = 7
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The access-token management surface.
|
|
43
|
+
*
|
|
44
|
+
* Presentational by contract: it performs no I/O of its own, renders exactly what it is given and
|
|
45
|
+
* reports what the user pressed. `ConnectedAccessTokensPanel` is this component wired to
|
|
46
|
+
* `useAccessTokens`; an application with its own transport mounts this one directly.
|
|
47
|
+
*/
|
|
48
|
+
export const AccessTokensPanel: FC<AccessTokensPanelProps> = ({
|
|
49
|
+
items, issued, loading, error, onCreate, onRevoke, onDismissIssued, usageHint
|
|
50
|
+
}) => {
|
|
51
|
+
const t = useI18nLib(AUTH_TOKEN_I18N, 'panel')
|
|
52
|
+
const [lng] = useLanguage()
|
|
53
|
+
const fieldId = useId()
|
|
54
|
+
|
|
55
|
+
const [createOpen, setCreateOpen] = useState(false)
|
|
56
|
+
const [name, setName] = useState('')
|
|
57
|
+
const [expiry, setExpiry] = useState<string>(String(TOKEN_EXPIRY_CHOICES[0]))
|
|
58
|
+
const [submitting, setSubmitting] = useState(false)
|
|
59
|
+
const [copied, setCopied] = useState(false)
|
|
60
|
+
const [revokeTarget, setRevokeTarget] = useState<string | null>(null)
|
|
61
|
+
const [revoking, setRevoking] = useState(false)
|
|
62
|
+
|
|
63
|
+
// An issued token FORCES the dialog open: it exists nowhere else, and a dialog that could be
|
|
64
|
+
// closed while it is set would lose the one copy the user was ever going to see.
|
|
65
|
+
const dialogOpen = createOpen || issued != null
|
|
66
|
+
|
|
67
|
+
const closeDialog = useCallback(() => {
|
|
68
|
+
setCreateOpen(false)
|
|
69
|
+
setCopied(false)
|
|
70
|
+
setName('')
|
|
71
|
+
setExpiry(String(TOKEN_EXPIRY_CHOICES[0]))
|
|
72
|
+
if (issued != null) {
|
|
73
|
+
onDismissIssued()
|
|
74
|
+
}
|
|
75
|
+
}, [issued, onDismissIssued])
|
|
76
|
+
|
|
77
|
+
const changeDialog = useCallback((next: boolean) => {
|
|
78
|
+
if (next) {
|
|
79
|
+
setCreateOpen(true)
|
|
80
|
+
} else {
|
|
81
|
+
closeDialog()
|
|
82
|
+
}
|
|
83
|
+
}, [closeDialog])
|
|
84
|
+
|
|
85
|
+
const submit = useCallback(async (event: FormEvent) => {
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
const trimmed = name.trim()
|
|
88
|
+
if (trimmed === '' || submitting) return
|
|
89
|
+
setSubmitting(true)
|
|
90
|
+
try {
|
|
91
|
+
await onCreate(expiry === TOKEN_EXPIRY_NEVER
|
|
92
|
+
? { name: trimmed }
|
|
93
|
+
: { name: trimmed, expiresIn: Number(expiry) * DAY_SECONDS })
|
|
94
|
+
} finally {
|
|
95
|
+
setSubmitting(false)
|
|
96
|
+
}
|
|
97
|
+
}, [name, expiry, submitting, onCreate])
|
|
98
|
+
|
|
99
|
+
const copy = useCallback(async () => {
|
|
100
|
+
if (issued == null) return
|
|
101
|
+
try {
|
|
102
|
+
await navigator.clipboard?.writeText(issued.token)
|
|
103
|
+
setCopied(true)
|
|
104
|
+
} catch {
|
|
105
|
+
// A missing or refused clipboard (an insecure origin, a denied permission) is not worth a
|
|
106
|
+
// failure banner — the value is on screen and selectable. The button simply doesn't claim a
|
|
107
|
+
// copy it did not make.
|
|
108
|
+
}
|
|
109
|
+
}, [issued])
|
|
110
|
+
|
|
111
|
+
const confirmRevoke = useCallback(async () => {
|
|
112
|
+
if (revokeTarget == null || revoking) return
|
|
113
|
+
setRevoking(true)
|
|
114
|
+
try {
|
|
115
|
+
await onRevoke(revokeTarget)
|
|
116
|
+
setRevokeTarget(null)
|
|
117
|
+
} finally {
|
|
118
|
+
setRevoking(false)
|
|
119
|
+
}
|
|
120
|
+
}, [revokeTarget, revoking, onRevoke])
|
|
121
|
+
|
|
122
|
+
return <Card>
|
|
123
|
+
<CardHeader>
|
|
124
|
+
<CardTitle>{t('title')}</CardTitle>
|
|
125
|
+
<CardDescription>{t('description')}</CardDescription>
|
|
126
|
+
<CardAction>
|
|
127
|
+
<Button type="button" data-testid="account-token-create" onClick={() => setCreateOpen(true)}>
|
|
128
|
+
<Plus aria-hidden />
|
|
129
|
+
{t('create')}
|
|
130
|
+
</Button>
|
|
131
|
+
</CardAction>
|
|
132
|
+
</CardHeader>
|
|
133
|
+
|
|
134
|
+
<CardContent className="flex flex-col gap-4">
|
|
135
|
+
{error != null && <Alert variant="destructive">
|
|
136
|
+
<AlertTitle>{t('error')}</AlertTitle>
|
|
137
|
+
<AlertDescription>{error.message}</AlertDescription>
|
|
138
|
+
</Alert>}
|
|
139
|
+
|
|
140
|
+
<Table>
|
|
141
|
+
<TableHeader>
|
|
142
|
+
<TableRow>
|
|
143
|
+
<TableHead>{t('col.display')}</TableHead>
|
|
144
|
+
<TableHead>{t('col.name')}</TableHead>
|
|
145
|
+
<TableHead>{t('col.created')}</TableHead>
|
|
146
|
+
<TableHead>{t('col.last-used')}</TableHead>
|
|
147
|
+
<TableHead>{t('col.expires')}</TableHead>
|
|
148
|
+
<TableHead>{t('col.status')}</TableHead>
|
|
149
|
+
<TableHead className="w-0"><span className="sr-only">{t('revoke')}</span></TableHead>
|
|
150
|
+
</TableRow>
|
|
151
|
+
</TableHeader>
|
|
152
|
+
<TableBody>
|
|
153
|
+
{loading === true && items.length === 0
|
|
154
|
+
? <TableRow>
|
|
155
|
+
<TableCell colSpan={COLUMNS} className="text-muted-foreground py-8 text-center">
|
|
156
|
+
<Loader2 className="mx-auto size-4 animate-spin" aria-hidden />
|
|
157
|
+
</TableCell>
|
|
158
|
+
</TableRow>
|
|
159
|
+
: items.length === 0
|
|
160
|
+
? <TableRow>
|
|
161
|
+
<TableCell colSpan={COLUMNS} className="text-muted-foreground py-8 text-center">
|
|
162
|
+
{t('empty')}
|
|
163
|
+
</TableCell>
|
|
164
|
+
</TableRow>
|
|
165
|
+
: items.map(item => {
|
|
166
|
+
const status = tokenStatus(item)
|
|
167
|
+
|
|
168
|
+
return <TableRow
|
|
169
|
+
key={item.id ?? item.display}
|
|
170
|
+
data-testid="account-token-row"
|
|
171
|
+
data-token-id={item.id}
|
|
172
|
+
>
|
|
173
|
+
<TableCell className="font-mono text-xs">{item.display}</TableCell>
|
|
174
|
+
<TableCell className="font-medium">{item.name}</TableCell>
|
|
175
|
+
<TableCell>{formatMoment(item.createdAt, lng) ?? ''}</TableCell>
|
|
176
|
+
<TableCell>{formatMoment(item.lastUsedAt, lng) ?? t('never-used')}</TableCell>
|
|
177
|
+
<TableCell>{formatMoment(item.expiresAt, lng) ?? t('never')}</TableCell>
|
|
178
|
+
<TableCell>
|
|
179
|
+
<Badge variant={STATUS_VARIANT[status]}>{t(`status.${status}`)}</Badge>
|
|
180
|
+
</TableCell>
|
|
181
|
+
<TableCell className="text-right">
|
|
182
|
+
<Button
|
|
183
|
+
type="button"
|
|
184
|
+
variant="ghost"
|
|
185
|
+
size="icon"
|
|
186
|
+
data-testid="account-token-revoke"
|
|
187
|
+
aria-label={t('revoke')}
|
|
188
|
+
title={t('revoke')}
|
|
189
|
+
disabled={status === 'revoked' || item.id == null}
|
|
190
|
+
onClick={() => { if (item.id != null) setRevokeTarget(item.id) }}
|
|
191
|
+
>
|
|
192
|
+
<Trash2 aria-hidden />
|
|
193
|
+
</Button>
|
|
194
|
+
</TableCell>
|
|
195
|
+
</TableRow>
|
|
196
|
+
})}
|
|
197
|
+
</TableBody>
|
|
198
|
+
</Table>
|
|
199
|
+
</CardContent>
|
|
200
|
+
|
|
201
|
+
<Dialog open={dialogOpen} onOpenChange={changeDialog}>
|
|
202
|
+
<DialogContent closeLabel={t('dialog.cancel')}>
|
|
203
|
+
{issued != null
|
|
204
|
+
? <>
|
|
205
|
+
<DialogHeader>
|
|
206
|
+
<DialogTitle>{t('dialog.issued-title')}</DialogTitle>
|
|
207
|
+
<DialogDescription>{t('dialog.issued-note')}</DialogDescription>
|
|
208
|
+
</DialogHeader>
|
|
209
|
+
<div className="flex flex-col gap-3">
|
|
210
|
+
<div className="flex items-center gap-2">
|
|
211
|
+
<Input
|
|
212
|
+
readOnly
|
|
213
|
+
value={issued.token}
|
|
214
|
+
data-testid="account-token-value"
|
|
215
|
+
className="font-mono text-xs"
|
|
216
|
+
onFocus={event => event.currentTarget.select()}
|
|
217
|
+
/>
|
|
218
|
+
<Button
|
|
219
|
+
type="button"
|
|
220
|
+
variant="outline"
|
|
221
|
+
data-testid="account-token-copy"
|
|
222
|
+
onClick={() => { void copy() }}
|
|
223
|
+
>
|
|
224
|
+
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
|
|
225
|
+
{copied ? t('dialog.copied') : t('dialog.copy')}
|
|
226
|
+
</Button>
|
|
227
|
+
</div>
|
|
228
|
+
{usageHint != null && usageHint !== ''
|
|
229
|
+
&& <p className="text-muted-foreground text-sm">{usageHint}</p>}
|
|
230
|
+
</div>
|
|
231
|
+
<DialogFooter>
|
|
232
|
+
<Button type="button" onClick={closeDialog}>{t('dialog.done')}</Button>
|
|
233
|
+
</DialogFooter>
|
|
234
|
+
</>
|
|
235
|
+
: <form onSubmit={event => { void submit(event) }}>
|
|
236
|
+
<DialogHeader>
|
|
237
|
+
<DialogTitle>{t('dialog.title')}</DialogTitle>
|
|
238
|
+
<DialogDescription>{t('dialog.description')}</DialogDescription>
|
|
239
|
+
</DialogHeader>
|
|
240
|
+
<div className="flex flex-col gap-4 py-4">
|
|
241
|
+
<div className="flex flex-col gap-2">
|
|
242
|
+
<Label htmlFor={`${fieldId}-name`}>{t('dialog.name-label')}</Label>
|
|
243
|
+
<Input
|
|
244
|
+
id={`${fieldId}-name`}
|
|
245
|
+
data-testid="account-token-name"
|
|
246
|
+
value={name}
|
|
247
|
+
maxLength={AUTH_TOKEN_NAME_MAX}
|
|
248
|
+
placeholder={t('dialog.name-placeholder')}
|
|
249
|
+
onChange={event => setName(event.target.value)}
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
<div className="flex flex-col gap-2">
|
|
253
|
+
<Label htmlFor={`${fieldId}-expiry`}>{t('dialog.expiry-label')}</Label>
|
|
254
|
+
<Select value={expiry} onValueChange={setExpiry}>
|
|
255
|
+
<SelectTrigger id={`${fieldId}-expiry`} className="w-full">
|
|
256
|
+
<SelectValue />
|
|
257
|
+
</SelectTrigger>
|
|
258
|
+
<SelectContent>
|
|
259
|
+
{TOKEN_EXPIRY_CHOICES.map(days => <SelectItem key={days} value={String(days)}>
|
|
260
|
+
{t(`dialog.expiry.${days}`)}
|
|
261
|
+
</SelectItem>)}
|
|
262
|
+
<SelectItem value={TOKEN_EXPIRY_NEVER}>{t('dialog.expiry.never')}</SelectItem>
|
|
263
|
+
</SelectContent>
|
|
264
|
+
</Select>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
<DialogFooter>
|
|
268
|
+
<Button type="button" variant="outline" onClick={closeDialog}>
|
|
269
|
+
{t('dialog.cancel')}
|
|
270
|
+
</Button>
|
|
271
|
+
<Button
|
|
272
|
+
type="submit"
|
|
273
|
+
data-testid="account-token-submit"
|
|
274
|
+
disabled={name.trim() === '' || submitting}
|
|
275
|
+
>
|
|
276
|
+
{submitting && <Loader2 className="animate-spin" aria-hidden />}
|
|
277
|
+
{t('dialog.create')}
|
|
278
|
+
</Button>
|
|
279
|
+
</DialogFooter>
|
|
280
|
+
</form>}
|
|
281
|
+
</DialogContent>
|
|
282
|
+
</Dialog>
|
|
283
|
+
|
|
284
|
+
<Dialog
|
|
285
|
+
open={revokeTarget != null}
|
|
286
|
+
onOpenChange={next => { if (!next) setRevokeTarget(null) }}
|
|
287
|
+
>
|
|
288
|
+
<DialogContent closeLabel={t('revoke-confirm.cancel')}>
|
|
289
|
+
<DialogHeader>
|
|
290
|
+
<DialogTitle>{t('revoke-confirm.title')}</DialogTitle>
|
|
291
|
+
<DialogDescription>{t('revoke-confirm.description')}</DialogDescription>
|
|
292
|
+
</DialogHeader>
|
|
293
|
+
<DialogFooter>
|
|
294
|
+
<Button type="button" variant="outline" onClick={() => setRevokeTarget(null)}>
|
|
295
|
+
{t('revoke-confirm.cancel')}
|
|
296
|
+
</Button>
|
|
297
|
+
<Button
|
|
298
|
+
type="button"
|
|
299
|
+
variant="destructive"
|
|
300
|
+
disabled={revoking}
|
|
301
|
+
onClick={() => { void confirmRevoke() }}
|
|
302
|
+
>
|
|
303
|
+
{revoking && <Loader2 className="animate-spin" aria-hidden />}
|
|
304
|
+
{t('revoke-confirm.confirm')}
|
|
305
|
+
</Button>
|
|
306
|
+
</DialogFooter>
|
|
307
|
+
</DialogContent>
|
|
308
|
+
</Dialog>
|
|
309
|
+
</Card>
|
|
310
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library-tier i18n resource this package registers under.
|
|
3
|
+
*
|
|
4
|
+
* Every string the panel renders is addressed as `lib : auth-token.panel.<key>`, so an application
|
|
5
|
+
* overrides any of them by registering the same resource at the app tier — see the README.
|
|
6
|
+
*/
|
|
7
|
+
export const AUTH_TOKEN_I18N = 'auth-token'
|
|
8
|
+
|
|
9
|
+
/** The lifetimes the create form offers, in days. */
|
|
10
|
+
export const TOKEN_EXPIRY_CHOICES = [30, 90, 365] as const
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The fourth choice, which is the absence of a lifetime.
|
|
14
|
+
*
|
|
15
|
+
* A `Select` carries strings, and `expiresIn` is simply omitted from the request body for this one
|
|
16
|
+
* — `CreateAccessToken` reads an absent lifetime as "no expiry".
|
|
17
|
+
*/
|
|
18
|
+
export const TOKEN_EXPIRY_NEVER = 'never'
|
|
19
|
+
|
|
20
|
+
/** `CreateAccessToken.expiresIn` is seconds, while the choices above are days. */
|
|
21
|
+
export const DAY_SECONDS = 24 * 60 * 60
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AccessTokenView } from '@owlmeans/auth-token'
|
|
2
|
+
import type { AccessTokenStatus } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A record's date field, whatever shape it arrived in.
|
|
6
|
+
*
|
|
7
|
+
* The record types say `Date`, and JSON over the wire says string — so a component that trusted
|
|
8
|
+
* the type would call `toLocaleString` on a string and throw. An unparseable value answers null
|
|
9
|
+
* rather than `Invalid Date`, which renders as those two words in every language.
|
|
10
|
+
*/
|
|
11
|
+
const toDate = (value: Date | string | null | undefined): Date | null => {
|
|
12
|
+
if (value == null) return null
|
|
13
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
14
|
+
|
|
15
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What a token's badge says.
|
|
20
|
+
*
|
|
21
|
+
* Revocation outranks expiry: a token revoked before its lifetime ran out is revoked, and saying
|
|
22
|
+
* "expired" about it would suggest it could be renewed. Both outrank `active`, which is what is
|
|
23
|
+
* left when neither happened.
|
|
24
|
+
*/
|
|
25
|
+
export const tokenStatus = (item: AccessTokenView): AccessTokenStatus => {
|
|
26
|
+
if (item.revokedAt != null) return 'revoked'
|
|
27
|
+
const expires = toDate(item.expiresAt)
|
|
28
|
+
|
|
29
|
+
return expires != null && expires.getTime() <= Date.now() ? 'expired' : 'active'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One of a record's moments, in the reader's own language, or null when there is none.
|
|
34
|
+
*
|
|
35
|
+
* Null is the caller's cue to render its own copy — `never` for a token without an expiry,
|
|
36
|
+
* `never-used` for one nothing has presented yet — because an empty cell says neither.
|
|
37
|
+
*/
|
|
38
|
+
export const formatMoment = (
|
|
39
|
+
value: Date | string | null | undefined, lng?: string
|
|
40
|
+
): string | null => {
|
|
41
|
+
const date = toDate(value)
|
|
42
|
+
if (date == null) return null
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
return new Intl.DateTimeFormat(lng, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
46
|
+
} catch {
|
|
47
|
+
// An unknown language tag throws rather than falling back, and a date is not worth a blank row.
|
|
48
|
+
return date.toISOString()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { useContext } from '@owlmeans/client'
|
|
3
|
+
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
4
|
+
import { ResilientError } from '@owlmeans/error'
|
|
5
|
+
import { authToken } from '@owlmeans/auth-token'
|
|
6
|
+
import type {
|
|
7
|
+
AccessTokenList, AccessTokenView, CreateAccessToken, IssuedAccessToken
|
|
8
|
+
} from '@owlmeans/auth-token'
|
|
9
|
+
import type { AccessTokensAliases, UseAccessTokens } from '../types.js'
|
|
10
|
+
|
|
11
|
+
/** The aliases `makeAuthTokenEntrypoints` declares, which is what an unconfigured host mounts. */
|
|
12
|
+
const DEFAULT_ALIASES: AccessTokensAliases = {
|
|
13
|
+
list: authToken.list,
|
|
14
|
+
create: authToken.create,
|
|
15
|
+
revoke: authToken.revoke,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The I/O half of the token surface.
|
|
20
|
+
*
|
|
21
|
+
* Nothing thrown here reaches the caller. A panel that cannot list its tokens must still render —
|
|
22
|
+
* with the failure on it — because the alternative is an error boundary swallowing the whole
|
|
23
|
+
* screen and a user who cannot even see that revoking is still possible. Every failure lands in
|
|
24
|
+
* `error` instead, and the next operation clears it.
|
|
25
|
+
*/
|
|
26
|
+
export const useAccessTokens = (aliases?: AccessTokensAliases): UseAccessTokens => {
|
|
27
|
+
const ctx = useContext()
|
|
28
|
+
const list = aliases?.list ?? DEFAULT_ALIASES.list
|
|
29
|
+
const create = aliases?.create ?? DEFAULT_ALIASES.create
|
|
30
|
+
const revoke = aliases?.revoke ?? DEFAULT_ALIASES.revoke
|
|
31
|
+
|
|
32
|
+
const [items, setItems] = useState<AccessTokenView[]>([])
|
|
33
|
+
const [issued, setIssued] = useState<IssuedAccessToken | null>(null)
|
|
34
|
+
const [loading, setLoading] = useState(true)
|
|
35
|
+
const [error, setError] = useState<Error | null>(null)
|
|
36
|
+
|
|
37
|
+
const reload = useCallback(async () => {
|
|
38
|
+
setLoading(true)
|
|
39
|
+
setError(null)
|
|
40
|
+
try {
|
|
41
|
+
const result = await ctx.entrypoint<ClientEntrypoint<AccessTokenList>>(list).call()
|
|
42
|
+
setItems(result.items)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
setError(ResilientError.ensure(e as Error))
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false)
|
|
47
|
+
}
|
|
48
|
+
}, [ctx, list])
|
|
49
|
+
|
|
50
|
+
useEffect(() => { void reload() }, [reload])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The plaintext is kept so the panel can show it once. It is never persisted anywhere — a reload
|
|
54
|
+
* of the page is the same as pressing Done, which is the property that makes showing it safe.
|
|
55
|
+
*/
|
|
56
|
+
const createToken = useCallback(async (body: CreateAccessToken) => {
|
|
57
|
+
setError(null)
|
|
58
|
+
try {
|
|
59
|
+
const result = await ctx.entrypoint<ClientEntrypoint<IssuedAccessToken>>(create).call({ body })
|
|
60
|
+
setIssued(result)
|
|
61
|
+
await reload()
|
|
62
|
+
} catch (e) {
|
|
63
|
+
setError(ResilientError.ensure(e as Error))
|
|
64
|
+
}
|
|
65
|
+
}, [ctx, create, reload])
|
|
66
|
+
|
|
67
|
+
const revokeToken = useCallback(async (id: string) => {
|
|
68
|
+
setError(null)
|
|
69
|
+
try {
|
|
70
|
+
await ctx.entrypoint<ClientEntrypoint<void>>(revoke).call({ params: { id } })
|
|
71
|
+
await reload()
|
|
72
|
+
} catch (e) {
|
|
73
|
+
setError(ResilientError.ensure(e as Error))
|
|
74
|
+
}
|
|
75
|
+
}, [ctx, revoke, reload])
|
|
76
|
+
|
|
77
|
+
const dismissIssued = useCallback(() => { setIssued(null) }, [])
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
items, issued, loading, error,
|
|
81
|
+
reload, create: createToken, revoke: revokeToken, dismissIssued,
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/i18n/be.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"panel": {
|
|
3
|
+
"title": "Токены доступу",
|
|
4
|
+
"description": "Доўгажывучыя токены, з якімі скрыпт ці інтэграцыя звяртаецца да API ад вашага імя.",
|
|
5
|
+
"create": "Стварыць токен",
|
|
6
|
+
"empty": "Токенаў доступу пакуль няма.",
|
|
7
|
+
"col": {
|
|
8
|
+
"display": "Токен",
|
|
9
|
+
"name": "Назва",
|
|
10
|
+
"created": "Створаны",
|
|
11
|
+
"last-used": "Апошняе выкарыстанне",
|
|
12
|
+
"expires": "Сканчаецца",
|
|
13
|
+
"status": "Статус"
|
|
14
|
+
},
|
|
15
|
+
"never": "Ніколі",
|
|
16
|
+
"never-used": "Не выкарыстоўваўся",
|
|
17
|
+
"status": {
|
|
18
|
+
"active": "Актыўны",
|
|
19
|
+
"expired": "Скончыўся",
|
|
20
|
+
"revoked": "Адкліканы"
|
|
21
|
+
},
|
|
22
|
+
"revoke": "Адклікаць",
|
|
23
|
+
"revoke-confirm": {
|
|
24
|
+
"title": "Адклікаць гэты токен?",
|
|
25
|
+
"description": "Усё, што яго яшчэ выкарыстоўвае, адразу перастане працаваць. Адрабіць гэта немагчыма.",
|
|
26
|
+
"cancel": "Скасаваць",
|
|
27
|
+
"confirm": "Адклікаць"
|
|
28
|
+
},
|
|
29
|
+
"dialog": {
|
|
30
|
+
"title": "Стварэнне токена доступу",
|
|
31
|
+
"description": "Назавіце яго паводле месца выкарыстання, каб пазней адрозніваць свае токены.",
|
|
32
|
+
"name-label": "Назва",
|
|
33
|
+
"name-placeholder": "Разгортванне CI",
|
|
34
|
+
"expiry-label": "Сканчаецца",
|
|
35
|
+
"expiry": {
|
|
36
|
+
"30": "Праз 30 дзён",
|
|
37
|
+
"90": "Праз 90 дзён",
|
|
38
|
+
"365": "Праз год",
|
|
39
|
+
"never": "Ніколі"
|
|
40
|
+
},
|
|
41
|
+
"create": "Стварыць",
|
|
42
|
+
"cancel": "Скасаваць",
|
|
43
|
+
"issued-title": "Ваш новы токен доступу",
|
|
44
|
+
"issued-note": "Скапіруйце яго зараз — больш ён не будзе паказаны.",
|
|
45
|
+
"copy": "Скапіраваць",
|
|
46
|
+
"copied": "Скапіравана",
|
|
47
|
+
"done": "Гатова"
|
|
48
|
+
},
|
|
49
|
+
"error": "Нешта пайшло не так"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/i18n/de.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"panel": {
|
|
3
|
+
"title": "Zugriffstoken",
|
|
4
|
+
"description": "Langlebige Token, mit denen ein Skript oder eine Integration die API in Ihrem Namen aufruft.",
|
|
5
|
+
"create": "Token erstellen",
|
|
6
|
+
"empty": "Noch keine Zugriffstoken vorhanden.",
|
|
7
|
+
"col": {
|
|
8
|
+
"display": "Token",
|
|
9
|
+
"name": "Name",
|
|
10
|
+
"created": "Erstellt",
|
|
11
|
+
"last-used": "Zuletzt verwendet",
|
|
12
|
+
"expires": "Läuft ab",
|
|
13
|
+
"status": "Status"
|
|
14
|
+
},
|
|
15
|
+
"never": "Nie",
|
|
16
|
+
"never-used": "Nie verwendet",
|
|
17
|
+
"status": {
|
|
18
|
+
"active": "Aktiv",
|
|
19
|
+
"expired": "Abgelaufen",
|
|
20
|
+
"revoked": "Widerrufen"
|
|
21
|
+
},
|
|
22
|
+
"revoke": "Widerrufen",
|
|
23
|
+
"revoke-confirm": {
|
|
24
|
+
"title": "Dieses Token widerrufen?",
|
|
25
|
+
"description": "Alles, was es noch verwendet, funktioniert sofort nicht mehr. Das lässt sich nicht rückgängig machen.",
|
|
26
|
+
"cancel": "Abbrechen",
|
|
27
|
+
"confirm": "Widerrufen"
|
|
28
|
+
},
|
|
29
|
+
"dialog": {
|
|
30
|
+
"title": "Zugriffstoken erstellen",
|
|
31
|
+
"description": "Benennen Sie es nach dem Einsatzort, damit Sie Ihre Token später auseinanderhalten.",
|
|
32
|
+
"name-label": "Name",
|
|
33
|
+
"name-placeholder": "CI-Deployment",
|
|
34
|
+
"expiry-label": "Läuft ab",
|
|
35
|
+
"expiry": {
|
|
36
|
+
"30": "In 30 Tagen",
|
|
37
|
+
"90": "In 90 Tagen",
|
|
38
|
+
"365": "In einem Jahr",
|
|
39
|
+
"never": "Nie"
|
|
40
|
+
},
|
|
41
|
+
"create": "Erstellen",
|
|
42
|
+
"cancel": "Abbrechen",
|
|
43
|
+
"issued-title": "Ihr neues Zugriffstoken",
|
|
44
|
+
"issued-note": "Kopieren Sie es jetzt – es wird nur dieses eine Mal angezeigt.",
|
|
45
|
+
"copy": "Kopieren",
|
|
46
|
+
"copied": "Kopiert",
|
|
47
|
+
"done": "Fertig"
|
|
48
|
+
},
|
|
49
|
+
"error": "Etwas ist schiefgelaufen"
|
|
50
|
+
}
|
|
51
|
+
}
|