@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.
Files changed (121) hide show
  1. package/README.md +82 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/web-auth-token/SKILL.md +128 -0
  4. package/build/@/components/ui/alert.d.ts +10 -0
  5. package/build/@/components/ui/alert.d.ts.map +1 -0
  6. package/build/@/components/ui/alert.js +26 -0
  7. package/build/@/components/ui/alert.js.map +1 -0
  8. package/build/@/components/ui/badge.d.ts +10 -0
  9. package/build/@/components/ui/badge.d.ts.map +1 -0
  10. package/build/@/components/ui/badge.js +23 -0
  11. package/build/@/components/ui/badge.js.map +1 -0
  12. package/build/@/components/ui/button.d.ts +11 -0
  13. package/build/@/components/ui/button.d.ts.map +1 -0
  14. package/build/@/components/ui/button.js +32 -0
  15. package/build/@/components/ui/button.js.map +1 -0
  16. package/build/@/components/ui/card.d.ts +10 -0
  17. package/build/@/components/ui/card.d.ts.map +1 -0
  18. package/build/@/components/ui/card.js +25 -0
  19. package/build/@/components/ui/card.js.map +1 -0
  20. package/build/@/components/ui/dialog.d.ts +17 -0
  21. package/build/@/components/ui/dialog.d.ts.map +1 -0
  22. package/build/@/components/ui/dialog.js +36 -0
  23. package/build/@/components/ui/dialog.js.map +1 -0
  24. package/build/@/components/ui/input.d.ts +4 -0
  25. package/build/@/components/ui/input.d.ts.map +1 -0
  26. package/build/@/components/ui/input.js +7 -0
  27. package/build/@/components/ui/input.js.map +1 -0
  28. package/build/@/components/ui/label.d.ts +5 -0
  29. package/build/@/components/ui/label.d.ts.map +1 -0
  30. package/build/@/components/ui/label.js +8 -0
  31. package/build/@/components/ui/label.js.map +1 -0
  32. package/build/@/components/ui/select.d.ts +16 -0
  33. package/build/@/components/ui/select.d.ts.map +1 -0
  34. package/build/@/components/ui/select.js +38 -0
  35. package/build/@/components/ui/select.js.map +1 -0
  36. package/build/@/components/ui/table.d.ts +11 -0
  37. package/build/@/components/ui/table.d.ts.map +1 -0
  38. package/build/@/components/ui/table.js +28 -0
  39. package/build/@/components/ui/table.js.map +1 -0
  40. package/build/@/lib/utils.d.ts +3 -0
  41. package/build/@/lib/utils.d.ts.map +1 -0
  42. package/build/@/lib/utils.js +6 -0
  43. package/build/@/lib/utils.js.map +1 -0
  44. package/build/components/connected.d.ts +16 -0
  45. package/build/components/connected.d.ts.map +1 -0
  46. package/build/components/connected.js +14 -0
  47. package/build/components/connected.js.map +1 -0
  48. package/build/components/index.d.ts +3 -0
  49. package/build/components/index.d.ts.map +1 -0
  50. package/build/components/index.js +3 -0
  51. package/build/components/index.js.map +1 -0
  52. package/build/components/panel.d.ts +11 -0
  53. package/build/components/panel.d.ts.map +1 -0
  54. package/build/components/panel.js +119 -0
  55. package/build/components/panel.js.map +1 -0
  56. package/build/consts.d.ts +19 -0
  57. package/build/consts.d.ts.map +1 -0
  58. package/build/consts.js +19 -0
  59. package/build/consts.js.map +1 -0
  60. package/build/helpers.d.ts +18 -0
  61. package/build/helpers.d.ts.map +1 -0
  62. package/build/helpers.js +45 -0
  63. package/build/helpers.js.map +1 -0
  64. package/build/hooks/use-access-tokens.d.ts +11 -0
  65. package/build/hooks/use-access-tokens.d.ts.map +1 -0
  66. package/build/hooks/use-access-tokens.js +74 -0
  67. package/build/hooks/use-access-tokens.js.map +1 -0
  68. package/build/i18n/be.json +51 -0
  69. package/build/i18n/de.json +51 -0
  70. package/build/i18n/en.json +51 -0
  71. package/build/i18n/es.json +51 -0
  72. package/build/i18n/pl.json +51 -0
  73. package/build/i18n/ru.json +51 -0
  74. package/build/i18n/uk.json +51 -0
  75. package/build/i18n.d.ts +2 -0
  76. package/build/i18n.d.ts.map +1 -0
  77. package/build/i18n.js +17 -0
  78. package/build/i18n.js.map +1 -0
  79. package/build/index.d.ts +8 -0
  80. package/build/index.d.ts.map +1 -0
  81. package/build/index.js +7 -0
  82. package/build/index.js.map +1 -0
  83. package/build/types.d.ts +54 -0
  84. package/build/types.d.ts.map +1 -0
  85. package/build/types.js +2 -0
  86. package/build/types.js.map +1 -0
  87. package/build/utils.d.ts +13 -0
  88. package/build/utils.d.ts.map +1 -0
  89. package/build/utils.js +14 -0
  90. package/build/utils.js.map +1 -0
  91. package/components.json +21 -0
  92. package/package.json +68 -0
  93. package/src/@/components/ui/alert.tsx +70 -0
  94. package/src/@/components/ui/badge.tsx +47 -0
  95. package/src/@/components/ui/button.tsx +60 -0
  96. package/src/@/components/ui/card.tsx +93 -0
  97. package/src/@/components/ui/dialog.tsx +147 -0
  98. package/src/@/components/ui/input.tsx +22 -0
  99. package/src/@/components/ui/label.tsx +23 -0
  100. package/src/@/components/ui/select.tsx +184 -0
  101. package/src/@/components/ui/table.tsx +115 -0
  102. package/src/@/globals.css +57 -0
  103. package/src/@/lib/utils.ts +6 -0
  104. package/src/components/connected.tsx +34 -0
  105. package/src/components/index.ts +2 -0
  106. package/src/components/panel.tsx +310 -0
  107. package/src/consts.ts +21 -0
  108. package/src/helpers.ts +50 -0
  109. package/src/hooks/use-access-tokens.ts +83 -0
  110. package/src/i18n/be.json +51 -0
  111. package/src/i18n/de.json +51 -0
  112. package/src/i18n/en.json +51 -0
  113. package/src/i18n/es.json +51 -0
  114. package/src/i18n/pl.json +51 -0
  115. package/src/i18n/ru.json +51 -0
  116. package/src/i18n/uk.json +51 -0
  117. package/src/i18n.ts +18 -0
  118. package/src/index.ts +7 -0
  119. package/src/types.ts +59 -0
  120. package/src/utils.ts +14 -0
  121. 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,6 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -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,2 @@
1
+ export * from './panel.js'
2
+ export * from './connected.js'
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }