@meith/web 0.23.1 → 0.23.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/app/(board)/[slug]/new/page.tsx +7 -5
- package/app/(board)/thread/[slug]/edit/page.tsx +7 -5
- package/app/(board)/thread/[slug]/page.tsx +19 -1
- package/app/(board)/thread/[slug]/reply/page.tsx +7 -5
- package/app/(board)/usercp/security/verify/page.tsx +3 -0
- package/app/admin/layout.tsx +4 -4
- package/package.json +48 -48
- package/src/components/account/credential-proof-form.tsx +14 -4
- package/src/components/account/two-factor-forms.tsx +4 -4
- package/src/components/admin/admin-forms.tsx +8 -13
- package/src/components/admin/badge-forms.tsx +6 -7
- package/src/components/admin/branding-forms.tsx +5 -10
- package/src/components/admin/form-bits.tsx +6 -4
- package/src/components/admin/forum-forms.tsx +180 -78
- package/src/components/admin/mail-test-card.tsx +2 -5
- package/src/components/admin/marketplace-forms.tsx +4 -5
- package/src/components/admin/marketplace-listing.tsx +1 -1
- package/src/components/admin/plugin-forms.tsx +2 -6
- package/src/components/admin/settings-form.tsx +2 -6
- package/src/components/admin/system-forms.tsx +2 -5
- package/src/components/auth/otp-field.tsx +142 -0
- package/src/components/auth/second-factor-form.tsx +4 -3
- package/src/components/content/composer-recovery.tsx +19 -1
- package/src/components/content/edit-post-form.tsx +3 -2
- package/src/components/content/markdown-editor.tsx +123 -104
- package/src/components/content/new-thread-form.tsx +3 -2
- package/src/components/content/reply-form.tsx +3 -2
- package/src/components/moderation/thread-surgery-form.tsx +51 -47
- package/src/components/moderation/thread-tools-form.tsx +67 -87
- package/src/server/marketplace-admin.ts +2 -2
- package/src/server/upgrade-notice.ts +1 -1
- package/src/theme/contract.fixture.ts +2 -2
- package/src/view/account-copy.ts +2 -0
- package/src/view/admin-copy.ts +9 -0
- package/src/view/admin-panel-copy.ts +2 -0
- package/src/view/auth-copy.ts +17 -10
- package/src/view/content-copy.ts +11 -6
- package/src/view/forum-display.ts +1 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useActionState } from 'react'
|
|
4
4
|
|
|
5
5
|
import type { MatrixCell, MatrixRow } from '@meith/authorization'
|
|
6
|
+
import { cn, Disclosure } from '@meith/ui'
|
|
6
7
|
|
|
7
8
|
import { PANEL_CARD, PANEL_LIST, PANEL_ROW } from '@/components/shell/panel-list'
|
|
8
9
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
|
|
19
20
|
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
20
21
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
21
|
-
import { INPUT } from './form-bits'
|
|
22
|
+
import { INPUT, Saved } from './form-bits'
|
|
22
23
|
|
|
23
24
|
const TOGGLES = [
|
|
24
25
|
{ name: 'isOpen', labelKey: 'adminForum.toggle.isOpen' },
|
|
@@ -48,11 +49,7 @@ export function ForumOptionsForm({ forum, copy }: { forum: ForumOptionsValues; c
|
|
|
48
49
|
return (
|
|
49
50
|
<form action={action} className="flex flex-col gap-4" noValidate>
|
|
50
51
|
<FormError message={state.error} />
|
|
51
|
-
{state.notice === 'saved'
|
|
52
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
53
|
-
{fromCopy(copy, 'admin.saved')}
|
|
54
|
-
</p>
|
|
55
|
-
)}
|
|
52
|
+
<Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
|
|
56
53
|
<input type="hidden" name="forumId" value={forum.id} />
|
|
57
54
|
|
|
58
55
|
<label className="flex flex-col gap-1 text-sm">
|
|
@@ -153,38 +150,145 @@ function effectiveLabel(
|
|
|
153
150
|
})
|
|
154
151
|
}
|
|
155
152
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
153
|
+
const PERM_GROUPS: readonly { readonly labelKey: string; readonly fields: readonly string[] }[] = [
|
|
154
|
+
{
|
|
155
|
+
labelKey: 'adminForum.permGroup.viewing',
|
|
156
|
+
fields: ['canView', 'canViewThreads', 'canViewOthersThreads', 'canSearch'],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
labelKey: 'adminForum.permGroup.posting',
|
|
160
|
+
fields: [
|
|
161
|
+
'canPostThreads',
|
|
162
|
+
'canPostReplies',
|
|
163
|
+
'canPostPolls',
|
|
164
|
+
'canVotePolls',
|
|
165
|
+
'canRateThreads',
|
|
166
|
+
'canSubscribe',
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
labelKey: 'adminForum.permGroup.ownContent',
|
|
171
|
+
fields: ['canEditOwnPosts', 'canDeleteOwnPosts', 'canDeleteOwnThreads', 'editTimeLimitMinutes'],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
labelKey: 'adminForum.permGroup.moderation',
|
|
175
|
+
fields: [
|
|
176
|
+
'canEditOthersPosts',
|
|
177
|
+
'canSoftDeletePosts',
|
|
178
|
+
'canViewUnapproved',
|
|
179
|
+
'canViewDeleted',
|
|
180
|
+
'canApproveContent',
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
labelKey: 'adminForum.permGroup.attachments',
|
|
185
|
+
fields: [
|
|
186
|
+
'canUploadAttachments',
|
|
187
|
+
'canDownloadAttachments',
|
|
188
|
+
'maxAttachmentsPerPost',
|
|
189
|
+
'maxAttachmentSizeKb',
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
labelKey: 'adminForum.permGroup.approvals',
|
|
194
|
+
fields: ['requiresThreadApproval', 'requiresPostApproval', 'requiresApprovalOnEdit'],
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
function firstSentence(text: string): string {
|
|
199
|
+
const at = text.indexOf('. ')
|
|
200
|
+
return at === -1 ? text : text.slice(0, at + 1)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function groupCells(
|
|
204
|
+
cells: readonly MatrixCell[],
|
|
205
|
+
): readonly { readonly labelKey: string; readonly cells: readonly MatrixCell[] }[] {
|
|
206
|
+
const byKey = new Map(cells.map((cell) => [cell.key, cell]))
|
|
207
|
+
const taken = new Set<string>()
|
|
208
|
+
const sections = PERM_GROUPS.map((group) => {
|
|
209
|
+
const found = group.fields.flatMap((key) => {
|
|
210
|
+
const cell = byKey.get(key)
|
|
211
|
+
if (cell === undefined) return []
|
|
212
|
+
taken.add(key)
|
|
213
|
+
return [cell]
|
|
214
|
+
})
|
|
215
|
+
return { labelKey: group.labelKey, cells: found }
|
|
216
|
+
}).filter((section) => section.cells.length > 0)
|
|
217
|
+
|
|
218
|
+
const leftover = cells.filter((cell) => !taken.has(cell.key))
|
|
219
|
+
return leftover.length === 0
|
|
220
|
+
? sections
|
|
221
|
+
: [...sections, { labelKey: 'adminForum.permGroup.other', cells: leftover }]
|
|
222
|
+
}
|
|
169
223
|
|
|
224
|
+
const SEGMENT =
|
|
225
|
+
'flex-1 cursor-pointer px-2.5 py-1 text-center text-xs font-medium text-muted-foreground transition-colors hover:text-foreground has-[:checked]:font-semibold has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-ring'
|
|
226
|
+
|
|
227
|
+
function TriState({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
|
|
170
228
|
const negative = cell.kind === 'negative'
|
|
229
|
+
const options = [
|
|
230
|
+
{
|
|
231
|
+
value: 'inherit',
|
|
232
|
+
label: fromCopy(copy, 'adminForum.perm.inherit'),
|
|
233
|
+
on: 'has-[:checked]:bg-secondary has-[:checked]:text-secondary-foreground',
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
value: 'grant',
|
|
237
|
+
label: negative
|
|
238
|
+
? fromCopy(copy, 'adminForum.perm.requiredOption')
|
|
239
|
+
: fromCopy(copy, 'adminForum.perm.grant'),
|
|
240
|
+
on: 'has-[:checked]:bg-primary has-[:checked]:text-primary-foreground',
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
value: 'deny',
|
|
244
|
+
label: negative
|
|
245
|
+
? fromCopy(copy, 'adminForum.perm.notRequiredOption')
|
|
246
|
+
: fromCopy(copy, 'adminForum.perm.deny'),
|
|
247
|
+
on: 'has-[:checked]:bg-destructive has-[:checked]:text-destructive-foreground',
|
|
248
|
+
},
|
|
249
|
+
]
|
|
250
|
+
|
|
171
251
|
return (
|
|
172
|
-
<
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
252
|
+
<fieldset className="flex w-full shrink-0 divide-x divide-border overflow-hidden rounded-md border border-border sm:w-56">
|
|
253
|
+
<legend className="sr-only">{firstSentence(cell.description)}</legend>
|
|
254
|
+
{options.map((option) => (
|
|
255
|
+
<label key={option.value} className={cn(SEGMENT, option.on)}>
|
|
256
|
+
<input
|
|
257
|
+
type="radio"
|
|
258
|
+
name={cell.key}
|
|
259
|
+
value={option.value}
|
|
260
|
+
defaultChecked={cell.control === option.value}
|
|
261
|
+
className="sr-only"
|
|
262
|
+
/>
|
|
263
|
+
{option.label}
|
|
264
|
+
</label>
|
|
265
|
+
))}
|
|
266
|
+
</fieldset>
|
|
185
267
|
)
|
|
186
268
|
}
|
|
187
269
|
|
|
270
|
+
function CellControl({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
|
|
271
|
+
if (cell.kind !== 'numeric') return <TriState cell={cell} copy={copy} />
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<input
|
|
275
|
+
type="number"
|
|
276
|
+
name={cell.key}
|
|
277
|
+
min={0}
|
|
278
|
+
placeholder={fromCopy(copy, 'adminForum.perm.inherit')}
|
|
279
|
+
defaultValue={cell.control}
|
|
280
|
+
className={cn(INPUT, 'w-full shrink-0 sm:w-56')}
|
|
281
|
+
/>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function overridesAside(row: MatrixRow, copy: Copy): string {
|
|
286
|
+
const count = row.cells.filter((cell) => cell.stored !== null).length
|
|
287
|
+
return count === 0
|
|
288
|
+
? fromCopy(copy, 'adminForum.inheritsAll')
|
|
289
|
+
: formatFromCopy(copy, 'adminForum.overridesSet', { count })
|
|
290
|
+
}
|
|
291
|
+
|
|
188
292
|
export function ForumPermissionRowForm({
|
|
189
293
|
forumId,
|
|
190
294
|
row,
|
|
@@ -199,36 +303,48 @@ export function ForumPermissionRowForm({
|
|
|
199
303
|
const [state, action] = useActionState(saveForumPermissionsAction, EMPTY_STATE)
|
|
200
304
|
|
|
201
305
|
return (
|
|
202
|
-
<
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
306
|
+
<Disclosure summary={row.groupTitle} aside={overridesAside(row, copy)}>
|
|
307
|
+
<form action={action} className="flex flex-col gap-5">
|
|
308
|
+
<FormError message={state.error} />
|
|
309
|
+
<input type="hidden" name="forumId" value={forumId} />
|
|
310
|
+
<input type="hidden" name="groupId" value={row.groupId} />
|
|
311
|
+
|
|
312
|
+
{groupCells(row.cells).map((section) => (
|
|
313
|
+
<section key={section.labelKey} className="flex flex-col gap-2.5">
|
|
314
|
+
<h4 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
|
315
|
+
{fromCopy(copy, section.labelKey)}
|
|
316
|
+
</h4>
|
|
317
|
+
{section.cells.map((cell) => (
|
|
318
|
+
<div
|
|
319
|
+
key={cell.key}
|
|
320
|
+
className="flex flex-col gap-1.5 border-b border-border/60 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
|
|
321
|
+
>
|
|
322
|
+
<div className="min-w-0">
|
|
323
|
+
<p className="text-sm font-medium" title={cell.description}>
|
|
324
|
+
{firstSentence(cell.description)}
|
|
325
|
+
</p>
|
|
326
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
327
|
+
{effectiveLabel(cell, forumTitles, copy)}
|
|
328
|
+
</p>
|
|
329
|
+
</div>
|
|
330
|
+
<CellControl cell={cell} copy={copy} />
|
|
331
|
+
</div>
|
|
332
|
+
))}
|
|
333
|
+
</section>
|
|
223
334
|
))}
|
|
224
|
-
</div>
|
|
225
335
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
336
|
+
<div className="flex items-center gap-3">
|
|
337
|
+
<SubmitButton>
|
|
338
|
+
{formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
|
|
339
|
+
</SubmitButton>
|
|
340
|
+
{state.notice === 'saved' && (
|
|
341
|
+
<span className="text-xs font-medium text-moderation-approved">
|
|
342
|
+
{fromCopy(copy, 'admin.saved')}
|
|
343
|
+
</span>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
</form>
|
|
347
|
+
</Disclosure>
|
|
232
348
|
)
|
|
233
349
|
}
|
|
234
350
|
|
|
@@ -248,11 +364,7 @@ export function CopyPermissionsForm({
|
|
|
248
364
|
return (
|
|
249
365
|
<form action={action} className="flex flex-col gap-3">
|
|
250
366
|
<FormError message={state.error} />
|
|
251
|
-
{state.notice === 'copied'
|
|
252
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
253
|
-
{fromCopy(copy, 'adminForum.copied')}
|
|
254
|
-
</p>
|
|
255
|
-
)}
|
|
367
|
+
<Saved when={state.notice === 'copied'}>{fromCopy(copy, 'adminForum.copied')}</Saved>
|
|
256
368
|
<input type="hidden" name="forumId" value={forumId} />
|
|
257
369
|
<div>
|
|
258
370
|
<SubmitButton>
|
|
@@ -300,11 +412,7 @@ export function CreateForumForm({
|
|
|
300
412
|
return (
|
|
301
413
|
<form action={action} className="flex flex-col gap-3" noValidate>
|
|
302
414
|
<FormError message={state.error} />
|
|
303
|
-
{state.notice === 'created'
|
|
304
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
305
|
-
{fromCopy(copy, 'admin.created')}
|
|
306
|
-
</p>
|
|
307
|
-
)}
|
|
415
|
+
<Saved when={state.notice === 'created'}>{fromCopy(copy, 'admin.created')}</Saved>
|
|
308
416
|
|
|
309
417
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
310
418
|
<label className="flex flex-col gap-1 text-sm">
|
|
@@ -368,11 +476,7 @@ export function MoveForumForm({
|
|
|
368
476
|
return (
|
|
369
477
|
<form action={action} className="flex flex-col gap-3" noValidate>
|
|
370
478
|
<FormError message={state.error} />
|
|
371
|
-
{state.notice === 'moved'
|
|
372
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
373
|
-
{fromCopy(copy, 'adminForum.moved')}
|
|
374
|
-
</p>
|
|
375
|
-
)}
|
|
479
|
+
<Saved when={state.notice === 'moved'}>{fromCopy(copy, 'adminForum.moved')}</Saved>
|
|
376
480
|
<input type="hidden" name="forumId" value={forumId} />
|
|
377
481
|
|
|
378
482
|
<label className="flex flex-col gap-1 text-sm">
|
|
@@ -471,9 +575,7 @@ export function ModeratorsPanel({
|
|
|
471
575
|
|
|
472
576
|
<form action={appoint} className={PANEL_CARD}>
|
|
473
577
|
<FormError message={appointState.error} />
|
|
474
|
-
{appointState.notice === 'saved'
|
|
475
|
-
<p className="text-sm text-muted-foreground">{fromCopy(copy, 'admin.saved')}</p>
|
|
476
|
-
)}
|
|
578
|
+
<Saved when={appointState.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
|
|
477
579
|
<h3 className="text-sm font-medium">{fromCopy(copy, 'adminForum.appointTitle')}</h3>
|
|
478
580
|
<input type="hidden" name="forumId" value={forumId} />
|
|
479
581
|
|
|
@@ -7,6 +7,7 @@ import { EMPTY_STATE, type FormState } from '@/server/auth-form-state'
|
|
|
7
7
|
import { sendTestMailAction } from '@/server/mail-test-actions'
|
|
8
8
|
|
|
9
9
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
10
|
+
import { Saved } from './form-bits'
|
|
10
11
|
|
|
11
12
|
export function MailTestCard({
|
|
12
13
|
summary,
|
|
@@ -61,11 +62,7 @@ export function MailTestCard({
|
|
|
61
62
|
</p>
|
|
62
63
|
)}
|
|
63
64
|
|
|
64
|
-
{state.notice !== undefined
|
|
65
|
-
<p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
66
|
-
{state.notice}
|
|
67
|
-
</p>
|
|
68
|
-
)}
|
|
65
|
+
<Saved when={state.notice !== undefined}>{state.notice}</Saved>
|
|
69
66
|
|
|
70
67
|
<form action={submit} className="flex flex-col gap-2">
|
|
71
68
|
<div>
|
|
@@ -7,6 +7,7 @@ import { refreshMarketplaceAction } from '@/server/marketplace-actions'
|
|
|
7
7
|
|
|
8
8
|
import { FormError } from '../auth/form-controls'
|
|
9
9
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
10
|
+
import { Saved } from './form-bits'
|
|
10
11
|
|
|
11
12
|
export function MarketplaceRefreshForm({ copy }: { copy: Copy }) {
|
|
12
13
|
const [state, action] = useActionState(refreshMarketplaceAction, EMPTY_STATE)
|
|
@@ -14,11 +15,9 @@ export function MarketplaceRefreshForm({ copy }: { copy: Copy }) {
|
|
|
14
15
|
return (
|
|
15
16
|
<form action={action} className="flex flex-col gap-2">
|
|
16
17
|
<FormError message={state.error} />
|
|
17
|
-
{state.notice === 'refreshed'
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
</p>
|
|
21
|
-
)}
|
|
18
|
+
<Saved when={state.notice === 'refreshed'}>
|
|
19
|
+
{fromCopy(copy, 'adminPanel.marketplace.refreshed')}
|
|
20
|
+
</Saved>
|
|
22
21
|
<button
|
|
23
22
|
type="submit"
|
|
24
23
|
className="inline-flex h-9 w-fit items-center justify-center rounded-md border border-border px-3 text-sm font-medium transition-colors hover:border-primary hover:bg-accent"
|
|
@@ -70,7 +70,7 @@ export function MarketplaceListingCard({
|
|
|
70
70
|
<ol className="flex flex-col gap-1">
|
|
71
71
|
{listing.installSteps.map((step) => (
|
|
72
72
|
<li key={step} className="text-xs">
|
|
73
|
-
{step.startsWith('
|
|
73
|
+
{step.startsWith('npm ') || step.startsWith('community ') ? (
|
|
74
74
|
<code className="rounded bg-muted px-1.5 py-0.5">{step}</code>
|
|
75
75
|
) : (
|
|
76
76
|
<span className="text-muted-foreground">{step}</span>
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
|
|
12
12
|
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
13
13
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
14
|
-
import { INPUT } from './form-bits'
|
|
14
|
+
import { INPUT, Saved } from './form-bits'
|
|
15
15
|
|
|
16
16
|
export interface PluginSettingField {
|
|
17
17
|
readonly key: string
|
|
@@ -87,11 +87,7 @@ export function PluginSettingsForm({
|
|
|
87
87
|
return (
|
|
88
88
|
<form action={action} className="flex flex-col gap-4" noValidate>
|
|
89
89
|
<FormError message={state.error} />
|
|
90
|
-
{state.notice === 'saved'
|
|
91
|
-
<p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
92
|
-
{fromCopy(copy, 'adminPanel.plugin.saved')}
|
|
93
|
-
</p>
|
|
94
|
-
)}
|
|
90
|
+
<Saved when={state.notice === 'saved'}>{fromCopy(copy, 'adminPanel.plugin.saved')}</Saved>
|
|
95
91
|
|
|
96
92
|
<input type="hidden" name="key" value={pluginKey} />
|
|
97
93
|
|
|
@@ -8,7 +8,7 @@ import type { SettingFieldModel, SettingGroupModel } from '@/view/admin-settings
|
|
|
8
8
|
|
|
9
9
|
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
10
10
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
11
|
-
import { INPUT } from './form-bits'
|
|
11
|
+
import { INPUT, Saved } from './form-bits'
|
|
12
12
|
|
|
13
13
|
function Control({ setting, copy }: { setting: SettingFieldModel; copy: Copy }) {
|
|
14
14
|
const { field, key, value } = setting
|
|
@@ -95,11 +95,7 @@ export function AdminSettingsForm({
|
|
|
95
95
|
return (
|
|
96
96
|
<form action={action} className="flex flex-col gap-8" noValidate>
|
|
97
97
|
<FormError message={state.error} />
|
|
98
|
-
{state.notice === 'saved'
|
|
99
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
100
|
-
{fromCopy(copy, 'adminPanel.setting.saved')}
|
|
101
|
-
</p>
|
|
102
|
-
)}
|
|
98
|
+
<Saved when={state.notice === 'saved'}>{fromCopy(copy, 'adminPanel.setting.saved')}</Saved>
|
|
103
99
|
{state.notice === 'unchanged' && (
|
|
104
100
|
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
105
101
|
{fromCopy(copy, 'adminPanel.setting.unchanged')}
|
|
@@ -14,13 +14,10 @@ import {
|
|
|
14
14
|
|
|
15
15
|
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
16
16
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
17
|
+
import { Saved } from './form-bits'
|
|
17
18
|
|
|
18
19
|
function Result({ children }: { children: React.ReactNode }) {
|
|
19
|
-
return
|
|
20
|
-
<p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
21
|
-
{children}
|
|
22
|
-
</p>
|
|
23
|
-
)
|
|
20
|
+
return <Saved>{children}</Saved>
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
export function PruneSessionsForm({ prunable, copy }: { prunable: number; copy: Copy }) {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { Input, Field as UiField } from '@meith/ui'
|
|
6
|
+
import { InputOTP, InputOTPGroup, InputOTPSlot, REGEXP_ONLY_DIGITS } from '@meith/ui/input-otp'
|
|
7
|
+
|
|
8
|
+
import { type Copy, fromCopy } from '../shell/copy'
|
|
9
|
+
|
|
10
|
+
const OTP_LENGTH = 6
|
|
11
|
+
const OTP_SLOTS = Array.from({ length: OTP_LENGTH }, (_, index) => index)
|
|
12
|
+
|
|
13
|
+
export interface OtpRecoveryCopy {
|
|
14
|
+
readonly label: string
|
|
15
|
+
readonly hint?: string | undefined
|
|
16
|
+
readonly toRecovery: string
|
|
17
|
+
readonly toApp: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function otpRecoveryFromCopy(copy: Copy, hint?: string): OtpRecoveryCopy {
|
|
21
|
+
return {
|
|
22
|
+
label: fromCopy(copy, 'otp.recoveryLabel'),
|
|
23
|
+
toRecovery: fromCopy(copy, 'otp.useRecovery'),
|
|
24
|
+
toApp: fromCopy(copy, 'otp.useApp'),
|
|
25
|
+
...(hint === undefined ? {} : { hint }),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OtpFieldProps {
|
|
30
|
+
readonly label: string
|
|
31
|
+
readonly name?: string
|
|
32
|
+
readonly id?: string
|
|
33
|
+
readonly hint?: string | undefined
|
|
34
|
+
readonly error?: string | undefined
|
|
35
|
+
readonly required?: boolean
|
|
36
|
+
readonly recovery?: OtpRecoveryCopy | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Toggle({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={onClick}
|
|
44
|
+
className="self-start text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
</button>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function OtpField({
|
|
52
|
+
label,
|
|
53
|
+
name = 'code',
|
|
54
|
+
id,
|
|
55
|
+
hint,
|
|
56
|
+
error,
|
|
57
|
+
required = true,
|
|
58
|
+
recovery,
|
|
59
|
+
}: OtpFieldProps) {
|
|
60
|
+
const [enhanced, setEnhanced] = useState(false)
|
|
61
|
+
const [value, setValue] = useState('')
|
|
62
|
+
const [useRecovery, setUseRecovery] = useState(false)
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setEnhanced(true)
|
|
66
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
if (!enhanced) {
|
|
69
|
+
return (
|
|
70
|
+
<UiField
|
|
71
|
+
name={name}
|
|
72
|
+
label={label}
|
|
73
|
+
error={error ?? null}
|
|
74
|
+
{...(id === undefined ? {} : { id })}
|
|
75
|
+
{...(hint === undefined ? {} : { description: hint })}
|
|
76
|
+
>
|
|
77
|
+
{(control) => (
|
|
78
|
+
<Input
|
|
79
|
+
{...control}
|
|
80
|
+
type="text"
|
|
81
|
+
autoComplete="one-time-code"
|
|
82
|
+
required={required}
|
|
83
|
+
{...(recovery === undefined ? { inputMode: 'numeric' as const } : {})}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
</UiField>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (recovery !== undefined && useRecovery) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="flex flex-col gap-2">
|
|
93
|
+
<UiField
|
|
94
|
+
name={name}
|
|
95
|
+
label={recovery.label}
|
|
96
|
+
error={error ?? null}
|
|
97
|
+
{...(id === undefined ? {} : { id })}
|
|
98
|
+
{...(recovery.hint === undefined ? {} : { description: recovery.hint })}
|
|
99
|
+
>
|
|
100
|
+
{(control) => (
|
|
101
|
+
<Input {...control} type="text" autoComplete="one-time-code" required={required} />
|
|
102
|
+
)}
|
|
103
|
+
</UiField>
|
|
104
|
+
<Toggle onClick={() => setUseRecovery(false)}>{recovery.toApp}</Toggle>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex flex-col gap-2">
|
|
111
|
+
<UiField
|
|
112
|
+
name={name}
|
|
113
|
+
label={label}
|
|
114
|
+
error={error ?? null}
|
|
115
|
+
{...(id === undefined ? {} : { id })}
|
|
116
|
+
{...(hint === undefined ? {} : { description: hint })}
|
|
117
|
+
>
|
|
118
|
+
{(control) => (
|
|
119
|
+
<InputOTP
|
|
120
|
+
{...control}
|
|
121
|
+
maxLength={OTP_LENGTH}
|
|
122
|
+
pattern={REGEXP_ONLY_DIGITS}
|
|
123
|
+
value={value}
|
|
124
|
+
onChange={setValue}
|
|
125
|
+
inputMode="numeric"
|
|
126
|
+
autoComplete="one-time-code"
|
|
127
|
+
required={required}
|
|
128
|
+
>
|
|
129
|
+
<InputOTPGroup>
|
|
130
|
+
{OTP_SLOTS.map((slot) => (
|
|
131
|
+
<InputOTPSlot key={slot} index={slot} />
|
|
132
|
+
))}
|
|
133
|
+
</InputOTPGroup>
|
|
134
|
+
</InputOTP>
|
|
135
|
+
)}
|
|
136
|
+
</UiField>
|
|
137
|
+
{recovery !== undefined ? (
|
|
138
|
+
<Toggle onClick={() => setUseRecovery(true)}>{recovery.toRecovery}</Toggle>
|
|
139
|
+
) : null}
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -8,7 +8,8 @@ import { abandonSecondFactorAction, verifySecondFactorAction } from '@/server/au
|
|
|
8
8
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
9
9
|
|
|
10
10
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
11
|
-
import {
|
|
11
|
+
import { FormError, SubmitButton } from './form-controls'
|
|
12
|
+
import { OtpField, otpRecoveryFromCopy } from './otp-field'
|
|
12
13
|
|
|
13
14
|
export function SecondFactorForm({
|
|
14
15
|
next,
|
|
@@ -26,15 +27,15 @@ export function SecondFactorForm({
|
|
|
26
27
|
<form action={action} className="flex flex-col gap-4" noValidate>
|
|
27
28
|
<FormError message={state.error} />
|
|
28
29
|
|
|
29
|
-
<
|
|
30
|
+
<OtpField
|
|
30
31
|
label={fromCopy(copy, 'authForm.secondFactor.code')}
|
|
31
32
|
name="code"
|
|
32
|
-
autoComplete="one-time-code"
|
|
33
33
|
hint={
|
|
34
34
|
recoveryCodesLeft > 0
|
|
35
35
|
? fromCopy(copy, 'authForm.secondFactor.recoveryHint')
|
|
36
36
|
: fromCopy(copy, 'authForm.secondFactor.recoveryHintExhausted')
|
|
37
37
|
}
|
|
38
|
+
recovery={otpRecoveryFromCopy(copy)}
|
|
38
39
|
/>
|
|
39
40
|
|
|
40
41
|
{next ? <input type="hidden" name="next" value={next} /> : null}
|