@meith/web 0.23.2 → 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)/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 +9 -25
- package/src/components/admin/mail-test-card.tsx +2 -5
- package/src/components/admin/marketplace-forms.tsx +4 -5
- 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/server/upgrade-notice.ts +1 -1
- package/src/view/account-copy.ts +2 -0
- package/src/view/admin-panel-copy.ts +2 -0
- package/src/view/auth-copy.ts +17 -10
|
@@ -47,6 +47,9 @@ export default async function VerifySecurityPage({
|
|
|
47
47
|
codeSubmit: tr.t('credentialProof.codeSubmit'),
|
|
48
48
|
passwordLabel: tr.t('credentialProof.passwordLabel'),
|
|
49
49
|
passwordSubmit: tr.t('credentialProof.passwordSubmit'),
|
|
50
|
+
recoveryLabel: tr.t('otp.recoveryLabel'),
|
|
51
|
+
useRecovery: tr.t('otp.useRecovery'),
|
|
52
|
+
useApp: tr.t('otp.useApp'),
|
|
50
53
|
}}
|
|
51
54
|
hasPassword={account?.passwordHash !== null && account !== null}
|
|
52
55
|
hasSecondFactor={factor.enrolled}
|
package/app/admin/layout.tsx
CHANGED
|
@@ -26,7 +26,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|
|
26
26
|
<main
|
|
27
27
|
id="board-content"
|
|
28
28
|
tabIndex={-1}
|
|
29
|
-
className="flex min-h-screen items-center justify-center px-6 py-12"
|
|
29
|
+
className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 py-12"
|
|
30
30
|
>
|
|
31
31
|
<AdminSignInForm
|
|
32
32
|
next="/admin"
|
|
@@ -34,6 +34,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|
|
34
34
|
idleMinutes={ADMIN_IDLE_MINUTES}
|
|
35
35
|
copy={adminFormsCopy(await getTranslator())}
|
|
36
36
|
/>
|
|
37
|
+
<a href="/" className="text-sm text-muted-foreground hover:text-foreground">
|
|
38
|
+
{await tr('adminPanel.cancel')}
|
|
39
|
+
</a>
|
|
37
40
|
</main>
|
|
38
41
|
)
|
|
39
42
|
}
|
|
@@ -64,9 +67,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|
|
64
67
|
{await tr('page.control-panel')}
|
|
65
68
|
</a>
|
|
66
69
|
<div className="ml-auto flex shrink-0 items-center gap-3 text-sm whitespace-nowrap sm:gap-4">
|
|
67
|
-
<a href="/" className="text-muted-foreground hover:text-foreground">
|
|
68
|
-
{await tr('page.board')}
|
|
69
|
-
</a>
|
|
70
70
|
<AdminSignOutForm copy={adminFormsCopy(t)} />
|
|
71
71
|
</div>
|
|
72
72
|
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.3",
|
|
4
4
|
"description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,53 +43,53 @@
|
|
|
43
43
|
"react-dom": "19.2.8",
|
|
44
44
|
"tailwindcss": "^4.3.3",
|
|
45
45
|
"typescript": "7.0.2",
|
|
46
|
-
"@meith/accounts": "0.23.
|
|
47
|
-
"@meith/admin": "0.23.
|
|
48
|
-
"@meith/antispam": "0.23.
|
|
49
|
-
"@meith/api": "0.23.
|
|
50
|
-
"@meith/attachments": "0.23.
|
|
51
|
-
"@meith/authorization": "0.23.
|
|
52
|
-
"@meith/avatars": "0.23.
|
|
53
|
-
"@meith/core": "0.23.
|
|
54
|
-
"@meith/db": "0.23.
|
|
55
|
-
"@meith/demo": "0.23.
|
|
56
|
-
"@meith/drafts": "0.23.
|
|
57
|
-
"@meith/drivers": "0.23.
|
|
58
|
-
"@meith/events": "0.23.
|
|
59
|
-
"@meith/forums": "0.23.
|
|
60
|
-
"@meith/groups": "0.23.
|
|
61
|
-
"@meith/i18n": "0.23.
|
|
62
|
-
"@meith/import": "0.23.
|
|
63
|
-
"@meith/install": "0.23.
|
|
64
|
-
"@meith/mail": "0.23.
|
|
65
|
-
"@meith/markdown": "0.23.
|
|
66
|
-
"@meith/marketplace": "0.23.
|
|
67
|
-
"@meith/messages": "0.23.
|
|
68
|
-
"@meith/moderation": "0.23.
|
|
69
|
-
"@meith/notifications": "0.23.
|
|
70
|
-
"@meith/plugin-calendar": "0.23.
|
|
71
|
-
"@meith/plugin-dues": "0.23.
|
|
72
|
-
"@meith/plugin-kit": "0.23.
|
|
73
|
-
"@meith/polls": "0.23.
|
|
74
|
-
"@meith/posts": "0.23.
|
|
75
|
-
"@meith/profile-fields": "0.23.
|
|
76
|
-
"@meith/relations": "0.23.
|
|
77
|
-
"@meith/reputation": "0.23.
|
|
78
|
-
"@meith/runtime": "0.23.
|
|
79
|
-
"@meith/
|
|
80
|
-
"@meith/
|
|
81
|
-
"@meith/signatures": "0.23.
|
|
82
|
-
"@meith/subscriptions": "0.23.
|
|
83
|
-
"@meith/tasks": "0.23.
|
|
84
|
-
"@meith/theme-clubhouse": "0.23.
|
|
85
|
-
"@meith/theme-
|
|
86
|
-
"@meith/theme-
|
|
87
|
-
"@meith/theme-midnight": "0.23.
|
|
88
|
-
"@meith/theme-phasebook": "0.23.
|
|
89
|
-
"@meith/theme-raidframe": "0.23.
|
|
90
|
-
"@meith/threads": "0.23.
|
|
91
|
-
"@meith/ui": "0.23.
|
|
92
|
-
"@meith/upgrade": "0.23.
|
|
46
|
+
"@meith/accounts": "0.23.3",
|
|
47
|
+
"@meith/admin": "0.23.3",
|
|
48
|
+
"@meith/antispam": "0.23.3",
|
|
49
|
+
"@meith/api": "0.23.3",
|
|
50
|
+
"@meith/attachments": "0.23.3",
|
|
51
|
+
"@meith/authorization": "0.23.3",
|
|
52
|
+
"@meith/avatars": "0.23.3",
|
|
53
|
+
"@meith/core": "0.23.3",
|
|
54
|
+
"@meith/db": "0.23.3",
|
|
55
|
+
"@meith/demo": "0.23.3",
|
|
56
|
+
"@meith/drafts": "0.23.3",
|
|
57
|
+
"@meith/drivers": "0.23.3",
|
|
58
|
+
"@meith/events": "0.23.3",
|
|
59
|
+
"@meith/forums": "0.23.3",
|
|
60
|
+
"@meith/groups": "0.23.3",
|
|
61
|
+
"@meith/i18n": "0.23.3",
|
|
62
|
+
"@meith/import": "0.23.3",
|
|
63
|
+
"@meith/install": "0.23.3",
|
|
64
|
+
"@meith/mail": "0.23.3",
|
|
65
|
+
"@meith/markdown": "0.23.3",
|
|
66
|
+
"@meith/marketplace": "0.23.3",
|
|
67
|
+
"@meith/messages": "0.23.3",
|
|
68
|
+
"@meith/moderation": "0.23.3",
|
|
69
|
+
"@meith/notifications": "0.23.3",
|
|
70
|
+
"@meith/plugin-calendar": "0.23.3",
|
|
71
|
+
"@meith/plugin-dues": "0.23.3",
|
|
72
|
+
"@meith/plugin-kit": "0.23.3",
|
|
73
|
+
"@meith/polls": "0.23.3",
|
|
74
|
+
"@meith/posts": "0.23.3",
|
|
75
|
+
"@meith/profile-fields": "0.23.3",
|
|
76
|
+
"@meith/relations": "0.23.3",
|
|
77
|
+
"@meith/reputation": "0.23.3",
|
|
78
|
+
"@meith/runtime": "0.23.3",
|
|
79
|
+
"@meith/search": "0.23.3",
|
|
80
|
+
"@meith/settings": "0.23.3",
|
|
81
|
+
"@meith/signatures": "0.23.3",
|
|
82
|
+
"@meith/subscriptions": "0.23.3",
|
|
83
|
+
"@meith/tasks": "0.23.3",
|
|
84
|
+
"@meith/theme-clubhouse": "0.23.3",
|
|
85
|
+
"@meith/theme-default": "0.23.3",
|
|
86
|
+
"@meith/theme-kit": "0.23.3",
|
|
87
|
+
"@meith/theme-midnight": "0.23.3",
|
|
88
|
+
"@meith/theme-phasebook": "0.23.3",
|
|
89
|
+
"@meith/theme-raidframe": "0.23.3",
|
|
90
|
+
"@meith/threads": "0.23.3",
|
|
91
|
+
"@meith/ui": "0.23.3",
|
|
92
|
+
"@meith/upgrade": "0.23.3"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
|
95
95
|
"dev": "next dev",
|
|
@@ -9,6 +9,7 @@ import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
|
9
9
|
import { proveCredentialAction } from '@/server/credential-proof-actions'
|
|
10
10
|
|
|
11
11
|
import { FormError } from '../auth/form-controls'
|
|
12
|
+
import { OtpField } from '../auth/otp-field'
|
|
12
13
|
|
|
13
14
|
export interface CredentialProofCopy {
|
|
14
15
|
readonly codeConsumed: string
|
|
@@ -16,6 +17,9 @@ export interface CredentialProofCopy {
|
|
|
16
17
|
readonly codeSubmit: string
|
|
17
18
|
readonly passwordLabel: string
|
|
18
19
|
readonly passwordSubmit: string
|
|
20
|
+
readonly recoveryLabel: string
|
|
21
|
+
readonly useRecovery: string
|
|
22
|
+
readonly useApp: string
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export function CredentialProofForm({
|
|
@@ -59,10 +63,16 @@ export function CredentialProofForm({
|
|
|
59
63
|
>
|
|
60
64
|
<input type="hidden" name="method" value="code" />
|
|
61
65
|
<input type="hidden" name="next" value={next} />
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
<OtpField
|
|
67
|
+
label={copy.codeLabel}
|
|
68
|
+
name="code"
|
|
69
|
+
recovery={{
|
|
70
|
+
label: copy.recoveryLabel,
|
|
71
|
+
toRecovery: copy.useRecovery,
|
|
72
|
+
toApp: copy.useApp,
|
|
73
|
+
hint: copy.codeConsumed,
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
66
76
|
<p className="text-sm text-muted-foreground">{copy.codeConsumed}</p>
|
|
67
77
|
<div>
|
|
68
78
|
<Button type="submit" variant="primary">
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { RECOVERY_CODES_FIELD } from '@/view/two-factor'
|
|
16
16
|
|
|
17
17
|
import { Field, FormError } from '../auth/form-controls'
|
|
18
|
+
import { OtpField, otpRecoveryFromCopy } from '../auth/otp-field'
|
|
18
19
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
19
20
|
|
|
20
21
|
const CARD = 'flex flex-col gap-4 rounded-lg border border-border bg-card p-5'
|
|
@@ -96,10 +97,9 @@ export function TwoFactorSetup({
|
|
|
96
97
|
|
|
97
98
|
<form action={action} className="flex flex-col gap-4">
|
|
98
99
|
<FormError message={state.error} />
|
|
99
|
-
<
|
|
100
|
+
<OtpField
|
|
100
101
|
label={fromCopy(copy, 'accountForm.twoFactor.codeLabel')}
|
|
101
102
|
name="code"
|
|
102
|
-
autoComplete="one-time-code"
|
|
103
103
|
hint={fromCopy(copy, 'accountForm.twoFactor.codeHint')}
|
|
104
104
|
/>
|
|
105
105
|
<div className="flex flex-wrap gap-2">
|
|
@@ -166,12 +166,12 @@ export function TwoFactorPanel({
|
|
|
166
166
|
autoComplete="current-password"
|
|
167
167
|
/>
|
|
168
168
|
) : (
|
|
169
|
-
<
|
|
169
|
+
<OtpField
|
|
170
170
|
id={`${which}-code`}
|
|
171
171
|
label={fromCopy(copy, 'accountForm.twoFactor.codeProofLabel')}
|
|
172
172
|
name="code"
|
|
173
|
-
autoComplete="one-time-code"
|
|
174
173
|
hint={fromCopy(copy, 'accountForm.twoFactor.codeProofHint')}
|
|
174
|
+
recovery={otpRecoveryFromCopy(copy)}
|
|
175
175
|
/>
|
|
176
176
|
)
|
|
177
177
|
|
|
@@ -6,6 +6,7 @@ import { adminSignInAction, adminSignOutAction } from '@/server/admin-actions'
|
|
|
6
6
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
7
7
|
|
|
8
8
|
import { FormError } from '../auth/form-controls'
|
|
9
|
+
import { OtpField, otpRecoveryFromCopy } from '../auth/otp-field'
|
|
9
10
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
10
11
|
|
|
11
12
|
export function AdminSignInForm({
|
|
@@ -50,19 +51,13 @@ export function AdminSignInForm({
|
|
|
50
51
|
/>
|
|
51
52
|
</label>
|
|
52
53
|
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
61
|
-
/>
|
|
62
|
-
<span className="text-xs text-muted-foreground">
|
|
63
|
-
{fromCopy(copy, 'adminPanel.twoFactorCodeHint')}
|
|
64
|
-
</span>
|
|
65
|
-
</label>
|
|
54
|
+
<OtpField
|
|
55
|
+
label={fromCopy(copy, 'adminPanel.twoFactorCode')}
|
|
56
|
+
name="code"
|
|
57
|
+
required={false}
|
|
58
|
+
hint={fromCopy(copy, 'adminPanel.twoFactorCodeHint')}
|
|
59
|
+
recovery={otpRecoveryFromCopy(copy)}
|
|
60
|
+
/>
|
|
66
61
|
|
|
67
62
|
<button
|
|
68
63
|
type="submit"
|
|
@@ -10,6 +10,7 @@ import { removeBadgeAction, saveBadgeAction } from '@/server/group-badge-actions
|
|
|
10
10
|
|
|
11
11
|
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
12
12
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
13
|
+
import { Saved } from './form-bits'
|
|
13
14
|
|
|
14
15
|
const GHOST =
|
|
15
16
|
'inline-flex h-8 items-center justify-center rounded-md border border-border px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
@@ -39,13 +40,11 @@ export function BadgeUploadForm({
|
|
|
39
40
|
<h3 className="text-sm font-medium">{label}</h3>
|
|
40
41
|
|
|
41
42
|
<FormError message={saved.error ?? removed.error} />
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</p>
|
|
48
|
-
)}
|
|
43
|
+
<Saved when={saved.notice === 'saved' || removed.notice === 'removed'}>
|
|
44
|
+
{saved.notice === 'saved'
|
|
45
|
+
? fromCopy(copy, 'admin.saved')
|
|
46
|
+
: fromCopy(copy, 'adminPanel.badge.removed')}
|
|
47
|
+
</Saved>
|
|
49
48
|
|
|
50
49
|
<div
|
|
51
50
|
className={`flex min-h-12 items-center justify-center rounded-md border border-border bg-card p-2 ${
|
|
@@ -8,6 +8,7 @@ import { removeLogoAction, saveLogoAction } from '@/server/branding-actions'
|
|
|
8
8
|
|
|
9
9
|
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
10
10
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
11
|
+
import { Saved } from './form-bits'
|
|
11
12
|
|
|
12
13
|
export interface LogoSlot {
|
|
13
14
|
readonly scheme: 'light' | 'dark'
|
|
@@ -41,16 +42,10 @@ export function LogoUploadForm({
|
|
|
41
42
|
</div>
|
|
42
43
|
|
|
43
44
|
<FormError message={saved.error ?? removed.error} />
|
|
44
|
-
{saved.notice === 'saved'
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)}
|
|
49
|
-
{removed.notice === 'removed' && (
|
|
50
|
-
<p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
51
|
-
{fromCopy(copy, 'adminPanel.branding.removed')}
|
|
52
|
-
</p>
|
|
53
|
-
)}
|
|
45
|
+
<Saved when={saved.notice === 'saved'}>{fromCopy(copy, 'adminPanel.branding.saved')}</Saved>
|
|
46
|
+
<Saved when={removed.notice === 'removed'}>
|
|
47
|
+
{fromCopy(copy, 'adminPanel.branding.removed')}
|
|
48
|
+
</Saved>
|
|
54
49
|
|
|
55
50
|
<div
|
|
56
51
|
className={`flex min-h-16 items-center justify-center rounded-md border border-border bg-card p-3 ${
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { Alert, AlertDescription } from '@meith/ui'
|
|
2
|
+
|
|
1
3
|
export const INPUT =
|
|
2
4
|
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
3
5
|
|
|
4
|
-
export function Saved({ when, children }: { when
|
|
6
|
+
export function Saved({ when = true, children }: { when?: boolean; children: React.ReactNode }) {
|
|
5
7
|
if (!when) return null
|
|
6
8
|
return (
|
|
7
|
-
<
|
|
8
|
-
{children}
|
|
9
|
-
</
|
|
9
|
+
<Alert tone="success">
|
|
10
|
+
<AlertDescription>{children}</AlertDescription>
|
|
11
|
+
</Alert>
|
|
10
12
|
)
|
|
11
13
|
}
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
|
|
20
20
|
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
21
21
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
22
|
-
import { INPUT } from './form-bits'
|
|
22
|
+
import { INPUT, Saved } from './form-bits'
|
|
23
23
|
|
|
24
24
|
const TOGGLES = [
|
|
25
25
|
{ name: 'isOpen', labelKey: 'adminForum.toggle.isOpen' },
|
|
@@ -49,11 +49,7 @@ export function ForumOptionsForm({ forum, copy }: { forum: ForumOptionsValues; c
|
|
|
49
49
|
return (
|
|
50
50
|
<form action={action} className="flex flex-col gap-4" noValidate>
|
|
51
51
|
<FormError message={state.error} />
|
|
52
|
-
{state.notice === 'saved'
|
|
53
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
54
|
-
{fromCopy(copy, 'admin.saved')}
|
|
55
|
-
</p>
|
|
56
|
-
)}
|
|
52
|
+
<Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
|
|
57
53
|
<input type="hidden" name="forumId" value={forum.id} />
|
|
58
54
|
|
|
59
55
|
<label className="flex flex-col gap-1 text-sm">
|
|
@@ -342,7 +338,9 @@ export function ForumPermissionRowForm({
|
|
|
342
338
|
{formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
|
|
343
339
|
</SubmitButton>
|
|
344
340
|
{state.notice === 'saved' && (
|
|
345
|
-
<span className="text-xs text-
|
|
341
|
+
<span className="text-xs font-medium text-moderation-approved">
|
|
342
|
+
{fromCopy(copy, 'admin.saved')}
|
|
343
|
+
</span>
|
|
346
344
|
)}
|
|
347
345
|
</div>
|
|
348
346
|
</form>
|
|
@@ -366,11 +364,7 @@ export function CopyPermissionsForm({
|
|
|
366
364
|
return (
|
|
367
365
|
<form action={action} className="flex flex-col gap-3">
|
|
368
366
|
<FormError message={state.error} />
|
|
369
|
-
{state.notice === 'copied'
|
|
370
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
371
|
-
{fromCopy(copy, 'adminForum.copied')}
|
|
372
|
-
</p>
|
|
373
|
-
)}
|
|
367
|
+
<Saved when={state.notice === 'copied'}>{fromCopy(copy, 'adminForum.copied')}</Saved>
|
|
374
368
|
<input type="hidden" name="forumId" value={forumId} />
|
|
375
369
|
<div>
|
|
376
370
|
<SubmitButton>
|
|
@@ -418,11 +412,7 @@ export function CreateForumForm({
|
|
|
418
412
|
return (
|
|
419
413
|
<form action={action} className="flex flex-col gap-3" noValidate>
|
|
420
414
|
<FormError message={state.error} />
|
|
421
|
-
{state.notice === 'created'
|
|
422
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
423
|
-
{fromCopy(copy, 'admin.created')}
|
|
424
|
-
</p>
|
|
425
|
-
)}
|
|
415
|
+
<Saved when={state.notice === 'created'}>{fromCopy(copy, 'admin.created')}</Saved>
|
|
426
416
|
|
|
427
417
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
428
418
|
<label className="flex flex-col gap-1 text-sm">
|
|
@@ -486,11 +476,7 @@ export function MoveForumForm({
|
|
|
486
476
|
return (
|
|
487
477
|
<form action={action} className="flex flex-col gap-3" noValidate>
|
|
488
478
|
<FormError message={state.error} />
|
|
489
|
-
{state.notice === 'moved'
|
|
490
|
-
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
491
|
-
{fromCopy(copy, 'adminForum.moved')}
|
|
492
|
-
</p>
|
|
493
|
-
)}
|
|
479
|
+
<Saved when={state.notice === 'moved'}>{fromCopy(copy, 'adminForum.moved')}</Saved>
|
|
494
480
|
<input type="hidden" name="forumId" value={forumId} />
|
|
495
481
|
|
|
496
482
|
<label className="flex flex-col gap-1 text-sm">
|
|
@@ -589,9 +575,7 @@ export function ModeratorsPanel({
|
|
|
589
575
|
|
|
590
576
|
<form action={appoint} className={PANEL_CARD}>
|
|
591
577
|
<FormError message={appointState.error} />
|
|
592
|
-
{appointState.notice === 'saved'
|
|
593
|
-
<p className="text-sm text-muted-foreground">{fromCopy(copy, 'admin.saved')}</p>
|
|
594
|
-
)}
|
|
578
|
+
<Saved when={appointState.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
|
|
595
579
|
<h3 className="text-sm font-medium">{fromCopy(copy, 'adminForum.appointTitle')}</h3>
|
|
596
580
|
<input type="hidden" name="forumId" value={forumId} />
|
|
597
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"
|
|
@@ -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}
|
|
@@ -6,7 +6,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
|
|
|
6
6
|
|
|
7
7
|
import { activeDefinitions } from './plugin-host'
|
|
8
8
|
|
|
9
|
-
export const CODE_VERSION = '0.23.
|
|
9
|
+
export const CODE_VERSION = '0.23.3'
|
|
10
10
|
|
|
11
11
|
export async function pendingUpgradeNotice(): Promise<string | null> {
|
|
12
12
|
if (env.DATA_SOURCE !== 'postgres') return null
|
package/src/view/account-copy.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Translator } from '@meith/i18n'
|
|
2
2
|
|
|
3
|
+
import { otpFieldCopy } from './auth-copy'
|
|
3
4
|
import { copyFor, splitAround } from './copy'
|
|
4
5
|
import { untranslated } from './time'
|
|
5
6
|
|
|
@@ -201,6 +202,7 @@ export function twoFactorFormsCopy(
|
|
|
201
202
|
'accountForm.twoFactor.codes': t.t('accountForm.twoFactor.codes', {
|
|
202
203
|
count: recoveryCodesLeft,
|
|
203
204
|
}),
|
|
205
|
+
...otpFieldCopy(t),
|
|
204
206
|
}
|
|
205
207
|
}
|
|
206
208
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Translator } from '@meith/i18n'
|
|
2
2
|
|
|
3
3
|
import { adminSharedCopy } from './admin-copy'
|
|
4
|
+
import { otpFieldCopy } from './auth-copy'
|
|
4
5
|
import { copyFor, patternCopy, splitAround } from './copy'
|
|
5
6
|
import { untranslated } from './time'
|
|
6
7
|
|
|
@@ -21,6 +22,7 @@ export function adminFormsCopy(t: Translator = untranslated()): Readonly<Record<
|
|
|
21
22
|
t,
|
|
22
23
|
),
|
|
23
24
|
...patternCopy(['adminPanel.idleNote'], t),
|
|
25
|
+
...otpFieldCopy(t),
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
package/src/view/auth-copy.ts
CHANGED
|
@@ -79,19 +79,26 @@ export function resendVerificationFormCopy(
|
|
|
79
79
|
return copyFor(['authForm.email', 'authForm.resend.submit'], t)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export function otpFieldCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
|
|
83
|
+
return copyFor(['otp.recoveryLabel', 'otp.useApp', 'otp.useRecovery'], t)
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
export function secondFactorFormCopy(
|
|
83
87
|
t: Translator = untranslated(),
|
|
84
88
|
): Readonly<Record<string, string>> {
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
return {
|
|
90
|
+
...copyFor(
|
|
91
|
+
[
|
|
92
|
+
'authForm.secondFactor.code',
|
|
93
|
+
'authForm.secondFactor.recoveryHint',
|
|
94
|
+
'authForm.secondFactor.recoveryHintExhausted',
|
|
95
|
+
'authForm.secondFactor.submit',
|
|
96
|
+
'authForm.secondFactor.cancel',
|
|
97
|
+
],
|
|
98
|
+
t,
|
|
99
|
+
),
|
|
100
|
+
...otpFieldCopy(t),
|
|
101
|
+
}
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
export function passkeyCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
|