@xk2800/nextjs-template 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +339 -0
- package/components/auth/authCard.tsx +92 -0
- package/components/auth/authShell.tsx +46 -0
- package/components/auth/deviceCheck.tsx +52 -0
- package/components/auth/emailPasswordLogin.tsx +136 -0
- package/components/auth/emailPasswordSignup.tsx +178 -0
- package/components/auth/forgotPasswordForm.tsx +66 -0
- package/components/auth/loginErrorRecovery.tsx +117 -0
- package/components/auth/logoutButtons.tsx +26 -0
- package/components/auth/oneTap.tsx +50 -0
- package/components/auth/passkeyLogin.tsx +50 -0
- package/components/auth/resetPasswordForm.tsx +112 -0
- package/components/auth/socialLogin.tsx +51 -0
- package/components/auth/twoFactorVerifyForm.tsx +100 -0
- package/components/dashboard/accountDetailsCard.tsx +58 -0
- package/components/dashboard/activityLogsCard.tsx +141 -0
- package/components/dashboard/activityLogsCardSkeleton.tsx +20 -0
- package/components/dashboard/activityLogsSection.tsx +16 -0
- package/components/dashboard/admin/activityLogsSection.tsx +13 -0
- package/components/dashboard/admin/adminActivityLogsSkeleton.tsx +34 -0
- package/components/dashboard/admin/adminActivityLogsTable.tsx +319 -0
- package/components/dashboard/admin/adminStatsGrid.tsx +30 -0
- package/components/dashboard/admin/adminStatsGridSkeleton.tsx +16 -0
- package/components/dashboard/admin/adminUserActivityLogCard.tsx +53 -0
- package/components/dashboard/admin/adminUserActivityLogTable.tsx +69 -0
- package/components/dashboard/admin/adminUserLoginInfoCard.tsx +50 -0
- package/components/dashboard/admin/adminUserLoginInfoTable.tsx +63 -0
- package/components/dashboard/admin/adminUserSessionsCard.tsx +225 -0
- package/components/dashboard/admin/adminUserStatusCard.tsx +57 -0
- package/components/dashboard/admin/adminUsersSkeleton.tsx +30 -0
- package/components/dashboard/admin/adminUsersTable.tsx +596 -0
- package/components/dashboard/admin/deviceThrottleCard.tsx +59 -0
- package/components/dashboard/admin/deviceThrottleTable.tsx +93 -0
- package/components/dashboard/admin/impersonationLogCard.tsx +73 -0
- package/components/dashboard/admin/newDeviceCard.tsx +35 -0
- package/components/dashboard/admin/newDeviceTable.tsx +71 -0
- package/components/dashboard/admin/revokeAllSessionsButton.tsx +77 -0
- package/components/dashboard/admin/sharedDeviceCard.tsx +42 -0
- package/components/dashboard/admin/sharedDeviceTable.tsx +75 -0
- package/components/dashboard/admin/systemSettingsForm.tsx +200 -0
- package/components/dashboard/admin/systemSettingsSection.tsx +9 -0
- package/components/dashboard/admin/systemSettingsSkeleton.tsx +29 -0
- package/components/dashboard/admin/usersSection.tsx +13 -0
- package/components/dashboard/adminSection.tsx +31 -0
- package/components/dashboard/appSidebar.tsx +170 -0
- package/components/dashboard/dashboardHeader.tsx +85 -0
- package/components/dashboard/heartbeat.tsx +22 -0
- package/components/dashboard/impersonationBanner.tsx +50 -0
- package/components/dashboard/passkeysCard.tsx +187 -0
- package/components/dashboard/profileCard.tsx +42 -0
- package/components/dashboard/sectionErrorFallback.tsx +18 -0
- package/components/dashboard/sessionsCard.tsx +194 -0
- package/components/dashboard/sessionsCardSkeleton.tsx +20 -0
- package/components/dashboard/sessionsSection.tsx +22 -0
- package/components/dashboard/twoFactorCard.tsx +344 -0
- package/components/dashboard/verifyEmailButton.tsx +32 -0
- package/components/email/email-template-2.tsx +44 -0
- package/components/email/email-template-new-device.tsx +80 -0
- package/components/email/email-template-passkey-change.tsx +81 -0
- package/components/email/email-template-reset-password.tsx +56 -0
- package/components/email/email-template-verify-email.tsx +55 -0
- package/components/email/email-template-welcome.tsx +53 -0
- package/components/email/email-template.tsx +33 -0
- package/components/email/send-email-button.tsx +61 -0
- package/components/providers/theme-provider.tsx +9 -0
- package/components/theme/theme-toggle.tsx +53 -0
- package/components/ui/alert-dialog.tsx +141 -0
- package/components/ui/avatar.tsx +53 -0
- package/components/ui/badge.tsx +46 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/checkbox.tsx +32 -0
- package/components/ui/data-table.tsx +302 -0
- package/components/ui/dialog.tsx +143 -0
- package/components/ui/dropdown-menu.tsx +203 -0
- package/components/ui/input.tsx +21 -0
- package/components/ui/label.tsx +26 -0
- package/components/ui/password-input.tsx +38 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/sheet.tsx +143 -0
- package/components/ui/sidebar.tsx +726 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +57 -0
- package/dist/activity/logger.d.mts +18 -0
- package/dist/activity/logger.d.ts +18 -0
- package/dist/activity/logger.js +289 -0
- package/dist/activity/logger.js.map +1 -0
- package/dist/activity/logger.mjs +7 -0
- package/dist/activity/logger.mjs.map +1 -0
- package/dist/activity/queries.d.mts +98 -0
- package/dist/activity/queries.d.ts +98 -0
- package/dist/activity/queries.js +372 -0
- package/dist/activity/queries.js.map +1 -0
- package/dist/activity/queries.mjs +124 -0
- package/dist/activity/queries.mjs.map +1 -0
- package/dist/admin/queries.d.mts +68 -0
- package/dist/admin/queries.d.ts +68 -0
- package/dist/admin/queries.js +450 -0
- package/dist/admin/queries.js.map +1 -0
- package/dist/admin/queries.mjs +143 -0
- package/dist/admin/queries.mjs.map +1 -0
- package/dist/auth/helpers.d.mts +111 -0
- package/dist/auth/helpers.d.ts +111 -0
- package/dist/auth/helpers.js +1023 -0
- package/dist/auth/helpers.js.map +1 -0
- package/dist/auth/helpers.mjs +67 -0
- package/dist/auth/helpers.mjs.map +1 -0
- package/dist/auth/index.d.mts +6589 -0
- package/dist/auth/index.d.ts +6589 -0
- package/dist/auth/index.js +968 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/index.mjs +10 -0
- package/dist/auth/index.mjs.map +1 -0
- package/dist/auth-client/index.d.mts +4864 -0
- package/dist/auth-client/index.d.ts +4864 -0
- package/dist/auth-client/index.js +55 -0
- package/dist/auth-client/index.js.map +1 -0
- package/dist/auth-client/index.mjs +49 -0
- package/dist/auth-client/index.mjs.map +1 -0
- package/dist/chunk-2FW7C3KS.mjs +556 -0
- package/dist/chunk-2FW7C3KS.mjs.map +1 -0
- package/dist/chunk-ENGYO4K7.mjs +189 -0
- package/dist/chunk-ENGYO4K7.mjs.map +1 -0
- package/dist/chunk-GTPTVNY2.mjs +40 -0
- package/dist/chunk-GTPTVNY2.mjs.map +1 -0
- package/dist/chunk-HYKUB3A2.mjs +30 -0
- package/dist/chunk-HYKUB3A2.mjs.map +1 -0
- package/dist/chunk-JIYHBWAU.mjs +79 -0
- package/dist/chunk-JIYHBWAU.mjs.map +1 -0
- package/dist/chunk-K6OJCSJS.mjs +41 -0
- package/dist/chunk-K6OJCSJS.mjs.map +1 -0
- package/dist/chunk-N6ECRCHY.mjs +61 -0
- package/dist/chunk-N6ECRCHY.mjs.map +1 -0
- package/dist/chunk-YN4436PK.mjs +33 -0
- package/dist/chunk-YN4436PK.mjs.map +1 -0
- package/dist/config/env.d.mts +15 -0
- package/dist/config/env.d.ts +15 -0
- package/dist/config/env.js +42 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/env.mjs +4 -0
- package/dist/config/env.mjs.map +1 -0
- package/dist/db/index.d.mts +10 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.js +252 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/index.mjs +6 -0
- package/dist/db/index.mjs.map +1 -0
- package/dist/db/schema.d.mts +2 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +186 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/schema.mjs +4 -0
- package/dist/db/schema.mjs.map +1 -0
- package/dist/schema-CgkDv4wJ.d.mts +1758 -0
- package/dist/schema-CgkDv4wJ.d.ts +1758 -0
- package/dist/sessions/queries.d.mts +34 -0
- package/dist/sessions/queries.d.ts +34 -0
- package/dist/sessions/queries.js +268 -0
- package/dist/sessions/queries.js.map +1 -0
- package/dist/sessions/queries.mjs +23 -0
- package/dist/sessions/queries.mjs.map +1 -0
- package/dist/settings/queries.d.mts +20 -0
- package/dist/settings/queries.d.ts +20 -0
- package/dist/settings/queries.js +344 -0
- package/dist/settings/queries.js.map +1 -0
- package/dist/settings/queries.mjs +7 -0
- package/dist/settings/queries.mjs.map +1 -0
- package/dist/types/auth/loginSchema.d.mts +8 -0
- package/dist/types/auth/loginSchema.d.ts +8 -0
- package/dist/types/auth/loginSchema.js +18 -0
- package/dist/types/auth/loginSchema.js.map +1 -0
- package/dist/types/auth/loginSchema.mjs +16 -0
- package/dist/types/auth/loginSchema.mjs.map +1 -0
- package/dist/types/auth/signupSchema.d.mts +9 -0
- package/dist/types/auth/signupSchema.d.ts +9 -0
- package/dist/types/auth/signupSchema.js +21 -0
- package/dist/types/auth/signupSchema.js.map +1 -0
- package/dist/types/auth/signupSchema.mjs +19 -0
- package/dist/types/auth/signupSchema.mjs.map +1 -0
- package/dist/users/queries.d.mts +71 -0
- package/dist/users/queries.d.ts +71 -0
- package/dist/users/queries.js +293 -0
- package/dist/users/queries.js.map +1 -0
- package/dist/users/queries.mjs +47 -0
- package/dist/users/queries.mjs.map +1 -0
- package/lib/auth-client.ts +58 -0
- package/lib/cookie-names.ts +13 -0
- package/lib/device-fingerprint.ts +21 -0
- package/lib/formatters.ts +53 -0
- package/lib/recalculate-columns.ts +75 -0
- package/lib/request-info.ts +78 -0
- package/lib/utils.ts +6 -0
- package/package.json +301 -0
- package/styles/theme.css +124 -0
- package/types/auth/loginSchema.ts +11 -0
- package/types/auth/signupSchema.ts +14 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type DeviceThrottleRow } from "@xk2800/nextjs-template/admin/queries"
|
|
4
|
+
import { Badge } from "../../ui/badge"
|
|
5
|
+
import { DataTable, type Column } from "../../ui/data-table"
|
|
6
|
+
import { formatDateTime } from "../../../lib/formatters"
|
|
7
|
+
|
|
8
|
+
function statusBadge(row: DeviceThrottleRow) {
|
|
9
|
+
if (row.blocked && row.windowActive) return <Badge variant="destructive">Blocked</Badge>
|
|
10
|
+
if (row.windowActive) return <Badge variant="secondary">Watching</Badge>
|
|
11
|
+
return <Badge variant="outline">Cooled off</Badge>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const columns: Column<DeviceThrottleRow>[] = [
|
|
15
|
+
{ name: 'status', title: 'Status', sticky: true, renderer: (row) => statusBadge(row) },
|
|
16
|
+
{
|
|
17
|
+
name: 'fingerprint',
|
|
18
|
+
title: 'Fingerprint',
|
|
19
|
+
minWidth: 180,
|
|
20
|
+
renderer: (row) => (
|
|
21
|
+
<span className="font-mono text-xs select-all whitespace-nowrap">{row.fingerprint}</span>
|
|
22
|
+
),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'ipAddress',
|
|
26
|
+
title: 'IP address',
|
|
27
|
+
renderer: (row) => (
|
|
28
|
+
<span className="font-mono text-xs select-all">{row.ipAddress || "Unknown"}</span>
|
|
29
|
+
),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'device',
|
|
33
|
+
title: 'Device',
|
|
34
|
+
renderer: (row) => (
|
|
35
|
+
<span className="text-sm">
|
|
36
|
+
{[row.browser, row.os].filter(Boolean).join(" · ") || "Unknown"}
|
|
37
|
+
<span className="block text-xs text-muted-foreground">{row.deviceType}</span>
|
|
38
|
+
</span>
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'location',
|
|
43
|
+
title: 'Location',
|
|
44
|
+
renderer: (row) => (
|
|
45
|
+
<span className="text-sm">
|
|
46
|
+
{[row.city, row.country].filter(Boolean).join(", ") || "Unknown"}
|
|
47
|
+
</span>
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'count',
|
|
52
|
+
title: 'Attempts',
|
|
53
|
+
renderer: (row) => (
|
|
54
|
+
<span className="block text-right font-medium tabular-nums">{row.count}</span>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'lastKind',
|
|
59
|
+
title: 'Kind',
|
|
60
|
+
renderer: (row) => (
|
|
61
|
+
<span className="text-xs text-muted-foreground">
|
|
62
|
+
{row.lastKind === "signup" ? "sign-up" : "sign-in"}
|
|
63
|
+
</span>
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'lastEmail',
|
|
68
|
+
title: 'Target email',
|
|
69
|
+
renderer: (row) => <span className="text-sm select-all">{row.lastEmail || "—"}</span>,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'windowStart',
|
|
73
|
+
title: 'Window started',
|
|
74
|
+
renderer: (row) => (
|
|
75
|
+
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
|
76
|
+
{formatDateTime(row.windowStart)}
|
|
77
|
+
</span>
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'lastAttemptAt',
|
|
82
|
+
title: 'Last attempt',
|
|
83
|
+
renderer: (row) => (
|
|
84
|
+
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
|
85
|
+
{formatDateTime(row.lastAttemptAt)}
|
|
86
|
+
</span>
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
export default function DeviceThrottleTable({ rows }: { rows: DeviceThrottleRow[] }) {
|
|
92
|
+
return <DataTable columns={columns} data={rows} getRowId={(row) => row.fingerprint} />
|
|
93
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import { ShieldAlert } from "lucide-react"
|
|
3
|
+
import { getRecentImpersonationEvents } from "@xk2800/nextjs-template/activity/queries"
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
5
|
+
import { Badge } from "../../ui/badge"
|
|
6
|
+
import { formatDateTime } from "../../../lib/formatters"
|
|
7
|
+
|
|
8
|
+
// Dedicated, always-visible feed of who's been logging in as whom — so an
|
|
9
|
+
// admin can see this at a glance instead of having to remember to filter the
|
|
10
|
+
// generic activity log for it. Rendered on both the Users page (where the
|
|
11
|
+
// impersonated accounts live) and the Audit Logs page. Styled like
|
|
12
|
+
// adminSection.tsx's amber callout to read as a distinct, security-sensitive
|
|
13
|
+
// section rather than just another card in the list.
|
|
14
|
+
interface ImpersonationLogCardProps {
|
|
15
|
+
// Skip the "View full log" link on the Audit Logs page itself, since the
|
|
16
|
+
// full filterable table is already right below this card there.
|
|
17
|
+
showViewAllLink?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function ImpersonationLogCard({ showViewAllLink = true }: ImpersonationLogCardProps) {
|
|
21
|
+
const events = await getRecentImpersonationEvents(10)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Card className="border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-500/[0.04]">
|
|
25
|
+
<CardHeader>
|
|
26
|
+
<div className="flex items-center gap-2">
|
|
27
|
+
<CardTitle>Impersonation log</CardTitle>
|
|
28
|
+
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400">
|
|
29
|
+
<ShieldAlert className="size-3" />
|
|
30
|
+
Security
|
|
31
|
+
</span>
|
|
32
|
+
</div>
|
|
33
|
+
<CardDescription>Most recent admin impersonation activity</CardDescription>
|
|
34
|
+
</CardHeader>
|
|
35
|
+
<CardContent>
|
|
36
|
+
{events.length === 0 ? (
|
|
37
|
+
<p className="text-sm text-gray-500 py-4">No impersonation activity recorded</p>
|
|
38
|
+
) : (
|
|
39
|
+
<ul className="divide-y divide-border">
|
|
40
|
+
{events.map((event) => (
|
|
41
|
+
<li key={event.id} className="flex items-center justify-between gap-4 py-3">
|
|
42
|
+
<div className="flex items-center gap-3">
|
|
43
|
+
<Badge variant={event.action === 'impersonation_started' ? 'destructive' : 'secondary'}>
|
|
44
|
+
{event.action === 'impersonation_started' ? 'Started' : 'Stopped'}
|
|
45
|
+
</Badge>
|
|
46
|
+
<div>
|
|
47
|
+
<p className="text-sm">{event.description}</p>
|
|
48
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
49
|
+
by {event.adminName || event.adminEmail || event.adminId}
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
54
|
+
{formatDateTime(event.createdAt)}
|
|
55
|
+
</span>
|
|
56
|
+
</li>
|
|
57
|
+
))}
|
|
58
|
+
</ul>
|
|
59
|
+
)}
|
|
60
|
+
{showViewAllLink && (
|
|
61
|
+
<div className="mt-4 pt-4 border-t border-amber-200 dark:border-amber-900/40">
|
|
62
|
+
<Link
|
|
63
|
+
href="/dashboard/admin/activity-logs"
|
|
64
|
+
className="text-sm font-medium text-amber-900 hover:underline dark:text-amber-400"
|
|
65
|
+
>
|
|
66
|
+
View full audit log →
|
|
67
|
+
</Link>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</CardContent>
|
|
71
|
+
</Card>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { MonitorSmartphone } from "lucide-react"
|
|
2
|
+
import { getRecentNewDevices } from "@xk2800/nextjs-template/admin/queries"
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
4
|
+
import NewDeviceTable from "./newDeviceTable"
|
|
5
|
+
|
|
6
|
+
// First sign-in from a device for each account (written by
|
|
7
|
+
// app/api/device-check/route.ts). The user and every admin also get an email;
|
|
8
|
+
// this is the durable, at-a-glance surface. If a row looks like a hijack,
|
|
9
|
+
// "Revoke sessions" logs that account out everywhere in one click, or open the
|
|
10
|
+
// account for the full session list / activity trail.
|
|
11
|
+
export default async function NewDeviceCard() {
|
|
12
|
+
const rows = await getRecentNewDevices(20)
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Card>
|
|
16
|
+
<CardHeader>
|
|
17
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
18
|
+
<MonitorSmartphone className="size-4" />
|
|
19
|
+
<CardTitle>New devices</CardTitle>
|
|
20
|
+
</div>
|
|
21
|
+
<CardDescription>
|
|
22
|
+
First sign-in from a device for each account, newest first. Revoke the
|
|
23
|
+
account's sessions if the sign-in wasn't them.
|
|
24
|
+
</CardDescription>
|
|
25
|
+
</CardHeader>
|
|
26
|
+
<CardContent>
|
|
27
|
+
{rows.length === 0 ? (
|
|
28
|
+
<p className="text-sm text-gray-500 py-4">No new-device sign-ins recorded yet</p>
|
|
29
|
+
) : (
|
|
30
|
+
<NewDeviceTable rows={rows} />
|
|
31
|
+
)}
|
|
32
|
+
</CardContent>
|
|
33
|
+
</Card>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
import { type RecentNewDeviceRow } from "@xk2800/nextjs-template/admin/queries"
|
|
5
|
+
import { DataTable, type Column } from "../../ui/data-table"
|
|
6
|
+
import { formatDateTime } from "../../../lib/formatters"
|
|
7
|
+
import RevokeAllSessionsButton from "./revokeAllSessionsButton"
|
|
8
|
+
|
|
9
|
+
const columns: Column<RecentNewDeviceRow>[] = [
|
|
10
|
+
{
|
|
11
|
+
name: 'account',
|
|
12
|
+
title: 'Account',
|
|
13
|
+
sticky: true,
|
|
14
|
+
renderer: (row) => (
|
|
15
|
+
<span className="text-sm">
|
|
16
|
+
<Link href={`/dashboard/admin/users/${row.userId}`} className="font-medium hover:underline">
|
|
17
|
+
{row.userName}
|
|
18
|
+
</Link>
|
|
19
|
+
<span className="block text-xs text-muted-foreground">{row.userEmail}</span>
|
|
20
|
+
</span>
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'device',
|
|
25
|
+
title: 'Device',
|
|
26
|
+
renderer: (row) => (
|
|
27
|
+
<span className="text-sm">
|
|
28
|
+
{[row.browser, row.os].filter(Boolean).join(" · ") || "Unknown"}
|
|
29
|
+
<span className="block text-xs text-muted-foreground">{row.deviceType}</span>
|
|
30
|
+
</span>
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'location',
|
|
35
|
+
title: 'Location',
|
|
36
|
+
renderer: (row) => (
|
|
37
|
+
<span className="text-sm">
|
|
38
|
+
{[row.city, row.country].filter(Boolean).join(", ") || "Unknown"}
|
|
39
|
+
</span>
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'ipAddress',
|
|
44
|
+
title: 'IP address',
|
|
45
|
+
renderer: (row) => (
|
|
46
|
+
<span className="font-mono text-xs select-all">{row.ipAddress || "Unknown"}</span>
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'createdAt',
|
|
51
|
+
title: 'When',
|
|
52
|
+
renderer: (row) => (
|
|
53
|
+
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
|
54
|
+
{formatDateTime(row.createdAt)}
|
|
55
|
+
</span>
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'actions',
|
|
60
|
+
title: 'Action',
|
|
61
|
+
renderer: (row) => (
|
|
62
|
+
<span className="block text-right">
|
|
63
|
+
<RevokeAllSessionsButton userId={row.userId} label="Revoke sessions" />
|
|
64
|
+
</span>
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
export default function NewDeviceTable({ rows }: { rows: RecentNewDeviceRow[] }) {
|
|
70
|
+
return <DataTable columns={columns} data={rows} getRowId={(row) => row.id} />
|
|
71
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
import { toast } from "sonner"
|
|
6
|
+
import { Button } from "../../ui/button"
|
|
7
|
+
import {
|
|
8
|
+
AlertDialog,
|
|
9
|
+
AlertDialogAction,
|
|
10
|
+
AlertDialogCancel,
|
|
11
|
+
AlertDialogContent,
|
|
12
|
+
AlertDialogDescription,
|
|
13
|
+
AlertDialogFooter,
|
|
14
|
+
AlertDialogHeader,
|
|
15
|
+
AlertDialogTitle,
|
|
16
|
+
} from "../../ui/alert-dialog"
|
|
17
|
+
|
|
18
|
+
// Kills every session for one account — the fast path when a new-device alert
|
|
19
|
+
// turns out to be a hijack. Hits the existing admin route with no sessionId,
|
|
20
|
+
// which deletes them all and writes a session_revoked audit entry.
|
|
21
|
+
export default function RevokeAllSessionsButton({
|
|
22
|
+
userId,
|
|
23
|
+
label = "Revoke all sessions",
|
|
24
|
+
size = "sm",
|
|
25
|
+
}: {
|
|
26
|
+
userId: string
|
|
27
|
+
label?: string
|
|
28
|
+
size?: "sm" | "default"
|
|
29
|
+
}) {
|
|
30
|
+
const router = useRouter()
|
|
31
|
+
const [open, setOpen] = useState(false)
|
|
32
|
+
const [busy, setBusy] = useState(false)
|
|
33
|
+
|
|
34
|
+
const revoke = async () => {
|
|
35
|
+
setBusy(true)
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(`/api/admin/users/${userId}/sessions`, { method: "DELETE" })
|
|
38
|
+
if (!res.ok) throw new Error()
|
|
39
|
+
toast.success("All sessions revoked")
|
|
40
|
+
setOpen(false)
|
|
41
|
+
router.refresh()
|
|
42
|
+
} catch {
|
|
43
|
+
toast.error("Failed to revoke sessions")
|
|
44
|
+
} finally {
|
|
45
|
+
setBusy(false)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<>
|
|
51
|
+
<Button variant="destructive" size={size} onClick={() => setOpen(true)}>
|
|
52
|
+
{label}
|
|
53
|
+
</Button>
|
|
54
|
+
<AlertDialog open={open} onOpenChange={setOpen}>
|
|
55
|
+
<AlertDialogContent>
|
|
56
|
+
<AlertDialogHeader>
|
|
57
|
+
<AlertDialogTitle>Revoke all sessions?</AlertDialogTitle>
|
|
58
|
+
<AlertDialogDescription>
|
|
59
|
+
Logs this account out of every device. Cached sessions may stay valid for
|
|
60
|
+
up to 5 minutes. The user can sign back in normally afterwards.
|
|
61
|
+
</AlertDialogDescription>
|
|
62
|
+
</AlertDialogHeader>
|
|
63
|
+
<AlertDialogFooter>
|
|
64
|
+
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
|
|
65
|
+
<AlertDialogAction
|
|
66
|
+
onClick={revoke}
|
|
67
|
+
disabled={busy}
|
|
68
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
69
|
+
>
|
|
70
|
+
{busy ? "Revoking..." : "Revoke all"}
|
|
71
|
+
</AlertDialogAction>
|
|
72
|
+
</AlertDialogFooter>
|
|
73
|
+
</AlertDialogContent>
|
|
74
|
+
</AlertDialog>
|
|
75
|
+
</>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Fingerprint, ShieldAlert } from "lucide-react"
|
|
2
|
+
import { getSharedDeviceAccounts } from "@xk2800/nextjs-template/admin/queries"
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
4
|
+
import { Badge } from "../../ui/badge"
|
|
5
|
+
import SharedDeviceTable from "./sharedDeviceTable"
|
|
6
|
+
|
|
7
|
+
// One physical device (FingerprintJS visitorId) that 3+ accounts have signed in
|
|
8
|
+
// from — the multi-account / trial-abuse signal. Each account links to its admin
|
|
9
|
+
// detail page, where it can be banned. Sits next to the Device throttle and New
|
|
10
|
+
// devices cards; a shared device that's also failing logins shows on all three.
|
|
11
|
+
export default async function SharedDeviceCard() {
|
|
12
|
+
const devices = await getSharedDeviceAccounts(20)
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Card className="border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-500/[0.04]">
|
|
16
|
+
<CardHeader>
|
|
17
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
18
|
+
<Fingerprint className="size-4" />
|
|
19
|
+
<CardTitle>Shared devices</CardTitle>
|
|
20
|
+
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400">
|
|
21
|
+
<ShieldAlert className="size-3" />
|
|
22
|
+
Security
|
|
23
|
+
</span>
|
|
24
|
+
{devices.length > 0 && (
|
|
25
|
+
<Badge variant="destructive">{devices.length} flagged</Badge>
|
|
26
|
+
)}
|
|
27
|
+
</div>
|
|
28
|
+
<CardDescription>
|
|
29
|
+
One device signed into by three or more accounts — a multi-account /
|
|
30
|
+
trial-abuse signal. Open an account to ban it.
|
|
31
|
+
</CardDescription>
|
|
32
|
+
</CardHeader>
|
|
33
|
+
<CardContent>
|
|
34
|
+
{devices.length === 0 ? (
|
|
35
|
+
<p className="text-sm text-gray-500 py-4">No devices shared across 3+ accounts</p>
|
|
36
|
+
) : (
|
|
37
|
+
<SharedDeviceTable devices={devices} />
|
|
38
|
+
)}
|
|
39
|
+
</CardContent>
|
|
40
|
+
</Card>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
import { type SharedDeviceRow } from "@xk2800/nextjs-template/admin/queries"
|
|
5
|
+
import { Badge } from "../../ui/badge"
|
|
6
|
+
import { DataTable, type Column } from "../../ui/data-table"
|
|
7
|
+
import { formatDateTime } from "../../../lib/formatters"
|
|
8
|
+
|
|
9
|
+
const columns: Column<SharedDeviceRow>[] = [
|
|
10
|
+
{
|
|
11
|
+
name: 'visitorId',
|
|
12
|
+
title: 'Device fingerprint',
|
|
13
|
+
sticky: true,
|
|
14
|
+
renderer: (d) => (
|
|
15
|
+
<span className="font-mono text-xs select-all break-all">{d.visitorId}</span>
|
|
16
|
+
),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'device',
|
|
20
|
+
title: 'Device',
|
|
21
|
+
renderer: (d) => (
|
|
22
|
+
<span className="text-sm">
|
|
23
|
+
{[d.browser, d.os].filter(Boolean).join(" · ") || "Unknown"}
|
|
24
|
+
<span className="block text-xs text-muted-foreground">{d.deviceType}</span>
|
|
25
|
+
</span>
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'location',
|
|
30
|
+
title: 'Location',
|
|
31
|
+
renderer: (d) => (
|
|
32
|
+
<span className="text-sm">{[d.city, d.country].filter(Boolean).join(", ") || "Unknown"}</span>
|
|
33
|
+
),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'accountCount',
|
|
37
|
+
title: 'Accounts',
|
|
38
|
+
renderer: (d) => (
|
|
39
|
+
<span className="block text-right font-medium tabular-nums">{d.accountCount}</span>
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'accounts',
|
|
44
|
+
title: 'Shared by',
|
|
45
|
+
minWidth: 220,
|
|
46
|
+
renderer: (d) => (
|
|
47
|
+
<ul className="space-y-1 text-sm">
|
|
48
|
+
{d.accounts.map((a) => (
|
|
49
|
+
<li key={a.userId}>
|
|
50
|
+
<Link href={`/dashboard/admin/users/${a.userId}`} className="font-medium hover:underline">
|
|
51
|
+
{a.userName}
|
|
52
|
+
</Link>
|
|
53
|
+
{a.banned && <Badge variant="outline" className="ml-2">Banned</Badge>}
|
|
54
|
+
<span className="block text-xs text-muted-foreground">
|
|
55
|
+
{a.userEmail} · first seen {formatDateTime(a.firstSeenAt)}
|
|
56
|
+
</span>
|
|
57
|
+
</li>
|
|
58
|
+
))}
|
|
59
|
+
</ul>
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'lastSeenAt',
|
|
64
|
+
title: 'Last seen',
|
|
65
|
+
renderer: (d) => (
|
|
66
|
+
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
|
67
|
+
{formatDateTime(d.lastSeenAt)}
|
|
68
|
+
</span>
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
export default function SharedDeviceTable({ devices }: { devices: SharedDeviceRow[] }) {
|
|
74
|
+
return <DataTable columns={columns} data={devices} getRowId={(d) => d.visitorId} />
|
|
75
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
import { toast } from "sonner"
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../ui/card"
|
|
7
|
+
import { Switch } from "../../ui/switch"
|
|
8
|
+
import { Label } from "../../ui/label"
|
|
9
|
+
import { Textarea } from "../../ui/textarea"
|
|
10
|
+
import { Button } from "../../ui/button"
|
|
11
|
+
import type { SystemSettings } from "@xk2800/nextjs-template/settings/queries"
|
|
12
|
+
|
|
13
|
+
interface ConfiguredAuthFlags {
|
|
14
|
+
google: boolean
|
|
15
|
+
emailPassword: boolean
|
|
16
|
+
oneTap: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SystemSettingsFormProps {
|
|
20
|
+
initialSettings: SystemSettings
|
|
21
|
+
configuredFlags: ConfiguredAuthFlags
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function SystemSettingsForm({ initialSettings, configuredFlags }: SystemSettingsFormProps) {
|
|
25
|
+
const router = useRouter()
|
|
26
|
+
const [settings, setSettings] = useState(initialSettings)
|
|
27
|
+
const [isSaving, setIsSaving] = useState(false)
|
|
28
|
+
|
|
29
|
+
// "Left enabled" means actually usable — configured (env) AND toggled on
|
|
30
|
+
// (DB) — not just the DB toggle, which can look "on" while being fully
|
|
31
|
+
// inert if the env var for that method is off.
|
|
32
|
+
const anySignInMethodLeft =
|
|
33
|
+
(configuredFlags.google && settings.authEnableGoogle) ||
|
|
34
|
+
(configuredFlags.emailPassword && settings.authEnableEmailPassword)
|
|
35
|
+
|
|
36
|
+
const handleSave = async () => {
|
|
37
|
+
setIsSaving(true)
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch('/api/admin/settings', {
|
|
40
|
+
method: 'PATCH',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
maintenanceMode: settings.maintenanceMode,
|
|
44
|
+
maintenanceMessage: settings.maintenanceMessage,
|
|
45
|
+
authEnableGoogle: settings.authEnableGoogle,
|
|
46
|
+
authEnableEmailPassword: settings.authEnableEmailPassword,
|
|
47
|
+
authEnableOneTap: settings.authEnableOneTap,
|
|
48
|
+
enableSessionRevocation: settings.enableSessionRevocation,
|
|
49
|
+
}),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const data = await response.json()
|
|
53
|
+
if (!response.ok) throw new Error(data.error || 'Failed to save settings')
|
|
54
|
+
|
|
55
|
+
toast.success('Settings saved')
|
|
56
|
+
router.refresh()
|
|
57
|
+
} catch (error) {
|
|
58
|
+
toast.error(error instanceof Error ? error.message : 'Failed to save settings')
|
|
59
|
+
} finally {
|
|
60
|
+
setIsSaving(false)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="space-y-6">
|
|
66
|
+
<Card>
|
|
67
|
+
<CardHeader>
|
|
68
|
+
<CardTitle>Authentication & Sessions</CardTitle>
|
|
69
|
+
<CardDescription>
|
|
70
|
+
Live toggles on top of what's configured via environment variables —
|
|
71
|
+
a method disabled here stops working immediately, without a redeploy
|
|
72
|
+
</CardDescription>
|
|
73
|
+
</CardHeader>
|
|
74
|
+
<CardContent className="space-y-4">
|
|
75
|
+
<div className="flex items-center justify-between">
|
|
76
|
+
<div>
|
|
77
|
+
<Label htmlFor="google-toggle">Google sign-in</Label>
|
|
78
|
+
<p className="text-sm text-gray-500">Show the Google button and accept sign-ins</p>
|
|
79
|
+
{!configuredFlags.google && (
|
|
80
|
+
<p className="text-sm text-amber-600 dark:text-amber-500">
|
|
81
|
+
Not configured for this deployment — set AUTH_ENABLE_GOOGLE=true (and Google credentials)
|
|
82
|
+
to unlock this toggle
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
<Switch
|
|
87
|
+
id="google-toggle"
|
|
88
|
+
checked={settings.authEnableGoogle}
|
|
89
|
+
disabled={!configuredFlags.google}
|
|
90
|
+
onCheckedChange={(checked) => setSettings({ ...settings, authEnableGoogle: checked })}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div className="flex items-center justify-between">
|
|
95
|
+
<div>
|
|
96
|
+
<Label htmlFor="email-toggle">Email & password sign-in</Label>
|
|
97
|
+
<p className="text-sm text-gray-500">Show the email/password form and signup page</p>
|
|
98
|
+
{!configuredFlags.emailPassword && (
|
|
99
|
+
<p className="text-sm text-amber-600 dark:text-amber-500">
|
|
100
|
+
Not configured for this deployment — set AUTH_ENABLE_EMAIL_PASSWORD=true to unlock this toggle
|
|
101
|
+
</p>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
<Switch
|
|
105
|
+
id="email-toggle"
|
|
106
|
+
checked={settings.authEnableEmailPassword}
|
|
107
|
+
disabled={!configuredFlags.emailPassword}
|
|
108
|
+
onCheckedChange={(checked) => setSettings({ ...settings, authEnableEmailPassword: checked })}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className="flex items-center justify-between">
|
|
113
|
+
<div>
|
|
114
|
+
<Label htmlFor="onetap-toggle">Google One Tap</Label>
|
|
115
|
+
<p className="text-sm text-gray-500">
|
|
116
|
+
Show the One Tap prompt on load — a shortcut into Google sign-in, not a
|
|
117
|
+
separate method; it stops working if Google sign-in above is off
|
|
118
|
+
</p>
|
|
119
|
+
{!configuredFlags.oneTap ? (
|
|
120
|
+
<p className="text-sm text-amber-600 dark:text-amber-500">
|
|
121
|
+
Not configured for this deployment — set AUTH_ENABLE_ONE_TAP=true
|
|
122
|
+
(and NEXT_PUBLIC_AUTH_ENABLE_ONE_TAP=true) to unlock this toggle
|
|
123
|
+
</p>
|
|
124
|
+
) : !settings.authEnableGoogle && (
|
|
125
|
+
<p className="text-sm text-amber-600 dark:text-amber-500">
|
|
126
|
+
Requires Google sign-in to be enabled above
|
|
127
|
+
</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
<Switch
|
|
131
|
+
id="onetap-toggle"
|
|
132
|
+
checked={settings.authEnableOneTap}
|
|
133
|
+
disabled={!configuredFlags.oneTap}
|
|
134
|
+
onCheckedChange={(checked) => setSettings({ ...settings, authEnableOneTap: checked })}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="flex items-center justify-between">
|
|
139
|
+
<div>
|
|
140
|
+
<Label htmlFor="revoke-toggle">Session revocation</Label>
|
|
141
|
+
<p className="text-sm text-gray-500">Let users revoke their own sessions from Settings</p>
|
|
142
|
+
</div>
|
|
143
|
+
<Switch
|
|
144
|
+
id="revoke-toggle"
|
|
145
|
+
checked={settings.enableSessionRevocation}
|
|
146
|
+
onCheckedChange={(checked) => setSettings({ ...settings, enableSessionRevocation: checked })}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{!anySignInMethodLeft && (
|
|
151
|
+
<p className="text-sm text-destructive">
|
|
152
|
+
At least one sign-in method must stay enabled, or nobody will be able to sign in.
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
</CardContent>
|
|
156
|
+
</Card>
|
|
157
|
+
|
|
158
|
+
<Card>
|
|
159
|
+
<CardHeader>
|
|
160
|
+
<CardTitle>Maintenance Mode</CardTitle>
|
|
161
|
+
<CardDescription>
|
|
162
|
+
Blocks the dashboard for non-admins and shows a maintenance page instead — admins keep full access
|
|
163
|
+
</CardDescription>
|
|
164
|
+
</CardHeader>
|
|
165
|
+
<CardContent className="space-y-4">
|
|
166
|
+
<div className="flex items-center justify-between">
|
|
167
|
+
<div>
|
|
168
|
+
<Label htmlFor="maintenance-toggle">Maintenance mode</Label>
|
|
169
|
+
<p className="text-sm text-gray-500">Non-admins are redirected to /maintenance</p>
|
|
170
|
+
</div>
|
|
171
|
+
<Switch
|
|
172
|
+
id="maintenance-toggle"
|
|
173
|
+
checked={settings.maintenanceMode}
|
|
174
|
+
onCheckedChange={(checked) => setSettings({ ...settings, maintenanceMode: checked })}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{settings.maintenanceMode && (
|
|
179
|
+
<div className="space-y-2">
|
|
180
|
+
<Label htmlFor="maintenance-message">Message shown to visitors (optional)</Label>
|
|
181
|
+
<Textarea
|
|
182
|
+
id="maintenance-message"
|
|
183
|
+
value={settings.maintenanceMessage ?? ''}
|
|
184
|
+
onChange={(e) => setSettings({ ...settings, maintenanceMessage: e.target.value || null })}
|
|
185
|
+
placeholder="We'll be back shortly."
|
|
186
|
+
maxLength={2000}
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</CardContent>
|
|
191
|
+
</Card>
|
|
192
|
+
|
|
193
|
+
<div className="flex justify-end">
|
|
194
|
+
<Button onClick={handleSave} disabled={isSaving || !anySignInMethodLeft}>
|
|
195
|
+
{isSaving ? 'Saving...' : 'Save changes'}
|
|
196
|
+
</Button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getSystemSettings, getConfiguredAuthFlags } from "@xk2800/nextjs-template/settings/queries"
|
|
2
|
+
import SystemSettingsForm from "./systemSettingsForm"
|
|
3
|
+
|
|
4
|
+
export default async function SystemSettingsSection() {
|
|
5
|
+
const settings = await getSystemSettings()
|
|
6
|
+
const configuredFlags = getConfiguredAuthFlags()
|
|
7
|
+
|
|
8
|
+
return <SystemSettingsForm initialSettings={settings} configuredFlags={configuredFlags} />
|
|
9
|
+
}
|