@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,29 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader } from "../../ui/card"
|
|
2
|
+
import { Skeleton } from "../../ui/skeleton"
|
|
3
|
+
|
|
4
|
+
export default function SystemSettingsSkeleton() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="space-y-6">
|
|
7
|
+
<Card>
|
|
8
|
+
<CardHeader>
|
|
9
|
+
<Skeleton className="h-6 w-40" />
|
|
10
|
+
</CardHeader>
|
|
11
|
+
<CardContent className="space-y-4">
|
|
12
|
+
{[...Array(4)].map((_, i) => (
|
|
13
|
+
<Skeleton key={i} className="h-10 w-full" />
|
|
14
|
+
))}
|
|
15
|
+
</CardContent>
|
|
16
|
+
</Card>
|
|
17
|
+
|
|
18
|
+
<Card>
|
|
19
|
+
<CardHeader>
|
|
20
|
+
<Skeleton className="h-6 w-40" />
|
|
21
|
+
</CardHeader>
|
|
22
|
+
<CardContent className="space-y-4">
|
|
23
|
+
<Skeleton className="h-10 w-full" />
|
|
24
|
+
<Skeleton className="h-20 w-full" />
|
|
25
|
+
</CardContent>
|
|
26
|
+
</Card>
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getUsersFiltered } from "@xk2800/nextjs-template/users/queries"
|
|
2
|
+
import AdminUsersTable from "./adminUsersTable"
|
|
3
|
+
|
|
4
|
+
export default async function UsersSection() {
|
|
5
|
+
const { users, total, page, limit, pages } = await getUsersFiltered({ page: 1, limit: 10 })
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<AdminUsersTable
|
|
9
|
+
initialUsers={users}
|
|
10
|
+
initialPagination={{ page, limit, total, pages }}
|
|
11
|
+
/>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import { ShieldCheck, LayoutDashboard } from "lucide-react"
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
|
4
|
+
import { Button } from "../ui/button"
|
|
5
|
+
|
|
6
|
+
export default function AdminSection() {
|
|
7
|
+
return (
|
|
8
|
+
<Card className="border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-500/[0.04]">
|
|
9
|
+
<CardHeader>
|
|
10
|
+
<div className="flex items-center gap-2">
|
|
11
|
+
<CardTitle>Admin panel</CardTitle>
|
|
12
|
+
<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">
|
|
13
|
+
<ShieldCheck className="size-3" />
|
|
14
|
+
Admin only
|
|
15
|
+
</span>
|
|
16
|
+
</div>
|
|
17
|
+
<CardDescription>
|
|
18
|
+
System stats, user management, and audit logs
|
|
19
|
+
</CardDescription>
|
|
20
|
+
</CardHeader>
|
|
21
|
+
<CardContent>
|
|
22
|
+
<Link href="/dashboard/admin">
|
|
23
|
+
<Button className="w-full">
|
|
24
|
+
<LayoutDashboard />
|
|
25
|
+
Open admin dashboard
|
|
26
|
+
</Button>
|
|
27
|
+
</Link>
|
|
28
|
+
</CardContent>
|
|
29
|
+
</Card>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
import { usePathname } from "next/navigation"
|
|
5
|
+
import {
|
|
6
|
+
LayoutDashboard,
|
|
7
|
+
Settings,
|
|
8
|
+
ShieldCheck,
|
|
9
|
+
Users,
|
|
10
|
+
ScrollText,
|
|
11
|
+
SlidersHorizontal,
|
|
12
|
+
Table,
|
|
13
|
+
LogOut,
|
|
14
|
+
} from "lucide-react"
|
|
15
|
+
|
|
16
|
+
import { authClient } from "@/lib/auth-client"
|
|
17
|
+
import {
|
|
18
|
+
Sidebar,
|
|
19
|
+
SidebarContent,
|
|
20
|
+
SidebarFooter,
|
|
21
|
+
SidebarGroup,
|
|
22
|
+
SidebarGroupContent,
|
|
23
|
+
SidebarGroupLabel,
|
|
24
|
+
SidebarHeader,
|
|
25
|
+
SidebarMenu,
|
|
26
|
+
SidebarMenuButton,
|
|
27
|
+
SidebarMenuItem,
|
|
28
|
+
} from "@/components/ui/sidebar"
|
|
29
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
30
|
+
import { Button } from "@/components/ui/button"
|
|
31
|
+
|
|
32
|
+
export interface AppSidebarUser {
|
|
33
|
+
name: string
|
|
34
|
+
email: string
|
|
35
|
+
image?: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AppSidebarProps {
|
|
39
|
+
user: AppSidebarUser
|
|
40
|
+
isAdmin: boolean
|
|
41
|
+
brandName?: React.ReactNode
|
|
42
|
+
brandHref?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const workspaceLinks = [
|
|
46
|
+
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
|
|
47
|
+
{ href: "/dashboard/data-table", label: "DataTable", icon: Table },
|
|
48
|
+
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const adminLinks = [
|
|
52
|
+
{ href: "/dashboard/admin", label: "Admin home", icon: ShieldCheck },
|
|
53
|
+
{ href: "/dashboard/admin/users", label: "Users", icon: Users },
|
|
54
|
+
{ href: "/dashboard/admin/activity-logs", label: "Audit logs", icon: ScrollText },
|
|
55
|
+
{ href: "/dashboard/admin/settings", label: "System settings", icon: SlidersHorizontal },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
function initials(name: string) {
|
|
59
|
+
return name
|
|
60
|
+
.split(" ")
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.slice(0, 2)
|
|
63
|
+
.map((part) => part[0]?.toUpperCase())
|
|
64
|
+
.join("")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default function AppSidebar({
|
|
68
|
+
user,
|
|
69
|
+
isAdmin,
|
|
70
|
+
brandName = "Template",
|
|
71
|
+
brandHref = "/",
|
|
72
|
+
}: AppSidebarProps) {
|
|
73
|
+
const pathname = usePathname()
|
|
74
|
+
|
|
75
|
+
const handleSignOut = async () => {
|
|
76
|
+
await authClient.signOut({
|
|
77
|
+
fetchOptions: {
|
|
78
|
+
onSuccess: () => {
|
|
79
|
+
window.location.href = "/"
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Sidebar collapsible="icon">
|
|
87
|
+
<SidebarHeader>
|
|
88
|
+
<Link
|
|
89
|
+
href={brandHref}
|
|
90
|
+
className="flex items-center gap-2 px-2 py-1.5 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:justify-center"
|
|
91
|
+
>
|
|
92
|
+
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-sidebar-primary-foreground" />
|
|
93
|
+
<span className="text-base font-semibold group-data-[collapsible=icon]:hidden">
|
|
94
|
+
{brandName}
|
|
95
|
+
</span>
|
|
96
|
+
</Link>
|
|
97
|
+
</SidebarHeader>
|
|
98
|
+
|
|
99
|
+
<SidebarContent>
|
|
100
|
+
<SidebarGroup>
|
|
101
|
+
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
|
|
102
|
+
<SidebarGroupContent>
|
|
103
|
+
<SidebarMenu>
|
|
104
|
+
{workspaceLinks.map((link) => (
|
|
105
|
+
<SidebarMenuItem key={link.href}>
|
|
106
|
+
<SidebarMenuButton
|
|
107
|
+
asChild
|
|
108
|
+
isActive={pathname === link.href}
|
|
109
|
+
tooltip={link.label}
|
|
110
|
+
>
|
|
111
|
+
<Link href={link.href}>
|
|
112
|
+
<link.icon />
|
|
113
|
+
<span>{link.label}</span>
|
|
114
|
+
</Link>
|
|
115
|
+
</SidebarMenuButton>
|
|
116
|
+
</SidebarMenuItem>
|
|
117
|
+
))}
|
|
118
|
+
</SidebarMenu>
|
|
119
|
+
</SidebarGroupContent>
|
|
120
|
+
</SidebarGroup>
|
|
121
|
+
|
|
122
|
+
{isAdmin && (
|
|
123
|
+
<SidebarGroup>
|
|
124
|
+
<SidebarGroupLabel>Administration</SidebarGroupLabel>
|
|
125
|
+
<SidebarGroupContent>
|
|
126
|
+
<SidebarMenu>
|
|
127
|
+
{adminLinks.map((link) => (
|
|
128
|
+
<SidebarMenuItem key={link.href}>
|
|
129
|
+
<SidebarMenuButton
|
|
130
|
+
asChild
|
|
131
|
+
isActive={pathname === link.href}
|
|
132
|
+
tooltip={link.label}
|
|
133
|
+
>
|
|
134
|
+
<Link href={link.href}>
|
|
135
|
+
<link.icon />
|
|
136
|
+
<span>{link.label}</span>
|
|
137
|
+
</Link>
|
|
138
|
+
</SidebarMenuButton>
|
|
139
|
+
</SidebarMenuItem>
|
|
140
|
+
))}
|
|
141
|
+
</SidebarMenu>
|
|
142
|
+
</SidebarGroupContent>
|
|
143
|
+
</SidebarGroup>
|
|
144
|
+
)}
|
|
145
|
+
</SidebarContent>
|
|
146
|
+
|
|
147
|
+
<SidebarFooter>
|
|
148
|
+
<div className="flex items-center gap-2 px-2 py-1.5 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0">
|
|
149
|
+
<Avatar className="size-8 shrink-0">
|
|
150
|
+
<AvatarImage src={user.image ?? undefined} alt={user.name} />
|
|
151
|
+
<AvatarFallback>{initials(user.name)}</AvatarFallback>
|
|
152
|
+
</Avatar>
|
|
153
|
+
<div className="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden">
|
|
154
|
+
<span className="truncate text-sm font-medium">{user.name}</span>
|
|
155
|
+
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
|
156
|
+
</div>
|
|
157
|
+
<Button
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="icon-sm"
|
|
160
|
+
onClick={handleSignOut}
|
|
161
|
+
aria-label="Log out"
|
|
162
|
+
className="group-data-[collapsible=icon]:hidden"
|
|
163
|
+
>
|
|
164
|
+
<LogOut />
|
|
165
|
+
</Button>
|
|
166
|
+
</div>
|
|
167
|
+
</SidebarFooter>
|
|
168
|
+
</Sidebar>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import LogoutButtons from "../auth/logoutButtons"
|
|
3
|
+
|
|
4
|
+
export interface DashboardNavLink {
|
|
5
|
+
href: string
|
|
6
|
+
label: string
|
|
7
|
+
active?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DashboardHeaderProps {
|
|
11
|
+
/**
|
|
12
|
+
* Omit on screens with no session (e.g. a public/marketing page) — the
|
|
13
|
+
* user name and LogoutButtons are only rendered when this is provided.
|
|
14
|
+
*/
|
|
15
|
+
userName?: string
|
|
16
|
+
/**
|
|
17
|
+
* Plain text (default: "Template") or any node — e.g. an `<img>`/`<Image>`
|
|
18
|
+
* logo, an inline SVG, or a logo+text combo. Rendered inside a `<Link>`,
|
|
19
|
+
* so avoid nesting another interactive/anchor element inside it.
|
|
20
|
+
*/
|
|
21
|
+
brandName?: React.ReactNode
|
|
22
|
+
brandHref?: string
|
|
23
|
+
navLinks?: DashboardNavLink[]
|
|
24
|
+
/**
|
|
25
|
+
* Rendered before the user name — e.g. pass `<ThemeToggle />` if your
|
|
26
|
+
* project uses next-themes. Omitted by default so DashboardHeader has no
|
|
27
|
+
* hard dependency on next-themes (it's an optional peer of the package).
|
|
28
|
+
*/
|
|
29
|
+
actions?: React.ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const defaultNavLinks: DashboardNavLink[] = [
|
|
33
|
+
{ href: "/dashboard", label: "Dashboard", active: true },
|
|
34
|
+
{ href: "/dashboard/settings", label: "Settings" },
|
|
35
|
+
{ href: "/", label: "Home" },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export default function DashboardHeader({
|
|
39
|
+
userName,
|
|
40
|
+
brandName = "Template",
|
|
41
|
+
brandHref = "/",
|
|
42
|
+
navLinks = defaultNavLinks,
|
|
43
|
+
actions,
|
|
44
|
+
}: DashboardHeaderProps) {
|
|
45
|
+
return (
|
|
46
|
+
<header className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
|
|
47
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
48
|
+
<div className="flex items-center justify-between">
|
|
49
|
+
<div className="flex items-center gap-8">
|
|
50
|
+
<Link href={brandHref} className="text-xl font-bold">
|
|
51
|
+
{brandName}
|
|
52
|
+
</Link>
|
|
53
|
+
<nav className="hidden md:flex items-center gap-6">
|
|
54
|
+
{navLinks.map((link) => (
|
|
55
|
+
<Link
|
|
56
|
+
key={link.href}
|
|
57
|
+
href={link.href}
|
|
58
|
+
className={
|
|
59
|
+
link.active
|
|
60
|
+
? "text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition"
|
|
61
|
+
: "text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition"
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
{link.label}
|
|
65
|
+
</Link>
|
|
66
|
+
))}
|
|
67
|
+
</nav>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="flex items-center gap-4">
|
|
71
|
+
{actions}
|
|
72
|
+
{userName && (
|
|
73
|
+
<>
|
|
74
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
75
|
+
{userName}
|
|
76
|
+
</span>
|
|
77
|
+
<LogoutButtons />
|
|
78
|
+
</>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</header>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
6
|
+
|
|
7
|
+
// Renders nothing — just keeps `users.lastActiveAt` fresh while an
|
|
8
|
+
// authenticated user has a dashboard tab open, so admins can see who's
|
|
9
|
+
// currently online (see ONLINE_THRESHOLD_MS in adminUsersPage).
|
|
10
|
+
export default function Heartbeat() {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const ping = () => {
|
|
13
|
+
fetch('/api/heartbeat', { method: 'POST' }).catch(() => {})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ping()
|
|
17
|
+
const interval = setInterval(ping, HEARTBEAT_INTERVAL_MS)
|
|
18
|
+
return () => clearInterval(interval)
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { authClient, useSession } from '../../lib/auth-client'
|
|
7
|
+
import { Button } from '../ui/button'
|
|
8
|
+
|
|
9
|
+
// Shown app-wide (see app/dashboard/layout.tsx) while the current session is
|
|
10
|
+
// an admin impersonating another user, so it's impossible to forget you're
|
|
11
|
+
// not looking at your own account.
|
|
12
|
+
export default function ImpersonationBanner() {
|
|
13
|
+
const { data: session } = useSession()
|
|
14
|
+
const router = useRouter()
|
|
15
|
+
const [isStopping, setIsStopping] = useState(false)
|
|
16
|
+
|
|
17
|
+
if (!session?.session.impersonatedBy) return null
|
|
18
|
+
|
|
19
|
+
const handleStop = async () => {
|
|
20
|
+
setIsStopping(true)
|
|
21
|
+
try {
|
|
22
|
+
const { error } = await authClient.admin.stopImpersonating()
|
|
23
|
+
if (error) throw new Error(error.message)
|
|
24
|
+
router.push('/dashboard/admin/users')
|
|
25
|
+
router.refresh()
|
|
26
|
+
} catch (error) {
|
|
27
|
+
toast.error('Failed to stop impersonating')
|
|
28
|
+
console.error(error)
|
|
29
|
+
} finally {
|
|
30
|
+
setIsStopping(false)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="sticky top-0 z-50 flex items-center justify-center gap-3 bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950">
|
|
36
|
+
<span>
|
|
37
|
+
You're viewing as <strong>{session.user.name || session.user.email}</strong>
|
|
38
|
+
</span>
|
|
39
|
+
<Button
|
|
40
|
+
variant="outline"
|
|
41
|
+
size="sm"
|
|
42
|
+
onClick={handleStop}
|
|
43
|
+
disabled={isStopping}
|
|
44
|
+
className="h-7 border-amber-950/30 bg-transparent text-amber-950 hover:bg-amber-950/10"
|
|
45
|
+
>
|
|
46
|
+
{isStopping ? 'Stopping...' : 'Stop impersonating'}
|
|
47
|
+
</Button>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card'
|
|
6
|
+
import { Button } from '../ui/button'
|
|
7
|
+
import { Input } from '../ui/input'
|
|
8
|
+
import { Skeleton } from '../ui/skeleton'
|
|
9
|
+
import { DataTable, type Column } from '../ui/data-table'
|
|
10
|
+
import {
|
|
11
|
+
AlertDialog,
|
|
12
|
+
AlertDialogAction,
|
|
13
|
+
AlertDialogCancel,
|
|
14
|
+
AlertDialogContent,
|
|
15
|
+
AlertDialogDescription,
|
|
16
|
+
AlertDialogFooter,
|
|
17
|
+
AlertDialogHeader,
|
|
18
|
+
AlertDialogTitle,
|
|
19
|
+
} from '../ui/alert-dialog'
|
|
20
|
+
import { formatDateTime } from '../../lib/formatters'
|
|
21
|
+
import { authClient } from '../../lib/auth-client'
|
|
22
|
+
|
|
23
|
+
type Passkey = {
|
|
24
|
+
id: string
|
|
25
|
+
name?: string | null
|
|
26
|
+
deviceType?: string | null
|
|
27
|
+
createdAt?: Date | string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const columns: Column<Passkey>[] = [
|
|
31
|
+
{ name: 'name', title: 'Name', sticky: true, renderer: (pk) => pk.name || 'Passkey' },
|
|
32
|
+
{
|
|
33
|
+
name: 'deviceType',
|
|
34
|
+
title: 'Type',
|
|
35
|
+
renderer: (pk) => (
|
|
36
|
+
<span className="text-sm text-muted-foreground">{pk.deviceType || '—'}</span>
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'createdAt',
|
|
41
|
+
title: 'Added',
|
|
42
|
+
renderer: (pk) => (
|
|
43
|
+
<span className="text-sm">{pk.createdAt ? formatDateTime(pk.createdAt) : '—'}</span>
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
export default function PasskeysCard() {
|
|
49
|
+
const [passkeys, setPasskeys] = useState<Passkey[] | null>(null)
|
|
50
|
+
const [name, setName] = useState('')
|
|
51
|
+
const [adding, setAdding] = useState(false)
|
|
52
|
+
const [toDelete, setToDelete] = useState<Passkey | null>(null)
|
|
53
|
+
const [deleting, setDeleting] = useState(false)
|
|
54
|
+
|
|
55
|
+
const load = useCallback(async () => {
|
|
56
|
+
const { data, error } = await authClient.passkey.listUserPasskeys()
|
|
57
|
+
if (error) {
|
|
58
|
+
toast.error(error.message || 'Could not load passkeys')
|
|
59
|
+
setPasskeys([])
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
setPasskeys((data ?? []) as Passkey[])
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
void load()
|
|
67
|
+
}, [load])
|
|
68
|
+
|
|
69
|
+
const addPasskey = async () => {
|
|
70
|
+
setAdding(true)
|
|
71
|
+
try {
|
|
72
|
+
const res = await authClient.passkey.addPasskey({ name: name.trim() || undefined })
|
|
73
|
+
if (res?.error) {
|
|
74
|
+
toast.error(res.error.message || 'Could not register passkey')
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
toast.success('Passkey registered')
|
|
78
|
+
setName('')
|
|
79
|
+
await load()
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// User cancelled the browser prompt, or no authenticator available.
|
|
82
|
+
toast.error(err instanceof Error ? err.message : 'Passkey registration cancelled')
|
|
83
|
+
} finally {
|
|
84
|
+
setAdding(false)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const deletePasskey = async (id: string) => {
|
|
89
|
+
setDeleting(true)
|
|
90
|
+
try {
|
|
91
|
+
const { error } = await authClient.passkey.deletePasskey({ id })
|
|
92
|
+
if (error) {
|
|
93
|
+
toast.error(error.message || 'Could not remove passkey')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
toast.success('Passkey removed')
|
|
97
|
+
setToDelete(null)
|
|
98
|
+
await load()
|
|
99
|
+
} finally {
|
|
100
|
+
setDeleting(false)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
<Card>
|
|
107
|
+
<CardHeader>
|
|
108
|
+
<CardTitle>Passkeys</CardTitle>
|
|
109
|
+
<CardDescription>
|
|
110
|
+
Sign in without a password using Touch ID, Windows Hello, or a
|
|
111
|
+
hardware security key.
|
|
112
|
+
</CardDescription>
|
|
113
|
+
</CardHeader>
|
|
114
|
+
<CardContent className="space-y-4">
|
|
115
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
116
|
+
<div className="flex flex-col gap-1">
|
|
117
|
+
<label htmlFor="passkey-name" className="text-sm font-medium">
|
|
118
|
+
Name (optional)
|
|
119
|
+
</label>
|
|
120
|
+
<Input
|
|
121
|
+
id="passkey-name"
|
|
122
|
+
placeholder="e.g. MacBook Touch ID"
|
|
123
|
+
value={name}
|
|
124
|
+
onChange={(e) => setName(e.target.value)}
|
|
125
|
+
className="w-64 max-w-full"
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
<Button onClick={addPasskey} disabled={adding}>
|
|
129
|
+
{adding ? 'Waiting for device...' : 'Add a passkey'}
|
|
130
|
+
</Button>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{passkeys === null ? (
|
|
134
|
+
<Skeleton className="h-20 w-full" />
|
|
135
|
+
) : passkeys.length === 0 ? (
|
|
136
|
+
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
137
|
+
No passkeys registered yet.
|
|
138
|
+
</p>
|
|
139
|
+
) : (
|
|
140
|
+
<DataTable
|
|
141
|
+
columns={[
|
|
142
|
+
...columns,
|
|
143
|
+
{
|
|
144
|
+
name: 'actions',
|
|
145
|
+
title: 'Actions',
|
|
146
|
+
renderer: (pk) => (
|
|
147
|
+
<Button
|
|
148
|
+
variant="destructive"
|
|
149
|
+
size="sm"
|
|
150
|
+
onClick={() => setToDelete(pk)}
|
|
151
|
+
>
|
|
152
|
+
Remove
|
|
153
|
+
</Button>
|
|
154
|
+
),
|
|
155
|
+
},
|
|
156
|
+
]}
|
|
157
|
+
data={passkeys}
|
|
158
|
+
getRowId={(pk) => pk.id}
|
|
159
|
+
/>
|
|
160
|
+
)}
|
|
161
|
+
</CardContent>
|
|
162
|
+
</Card>
|
|
163
|
+
|
|
164
|
+
<AlertDialog open={!!toDelete} onOpenChange={() => setToDelete(null)}>
|
|
165
|
+
<AlertDialogContent>
|
|
166
|
+
<AlertDialogHeader>
|
|
167
|
+
<AlertDialogTitle>Remove this passkey?</AlertDialogTitle>
|
|
168
|
+
<AlertDialogDescription>
|
|
169
|
+
You won't be able to sign in with{' '}
|
|
170
|
+
{toDelete?.name ? `"${toDelete.name}"` : 'this passkey'} anymore.
|
|
171
|
+
</AlertDialogDescription>
|
|
172
|
+
</AlertDialogHeader>
|
|
173
|
+
<AlertDialogFooter>
|
|
174
|
+
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
|
175
|
+
<AlertDialogAction
|
|
176
|
+
onClick={() => toDelete && deletePasskey(toDelete.id)}
|
|
177
|
+
disabled={deleting}
|
|
178
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
179
|
+
>
|
|
180
|
+
{deleting ? 'Removing...' : 'Remove'}
|
|
181
|
+
</AlertDialogAction>
|
|
182
|
+
</AlertDialogFooter>
|
|
183
|
+
</AlertDialogContent>
|
|
184
|
+
</AlertDialog>
|
|
185
|
+
</>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
|
2
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
|
|
3
|
+
import { Badge } from "../ui/badge"
|
|
4
|
+
import { getUserInitials } from "../../lib/formatters"
|
|
5
|
+
|
|
6
|
+
type User = {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
email: string
|
|
10
|
+
image?: string | null
|
|
11
|
+
role: string | null | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function ProfileCard({ user, isAdmin }: { user: User; isAdmin: boolean }) {
|
|
15
|
+
return (
|
|
16
|
+
<Card>
|
|
17
|
+
<CardHeader>
|
|
18
|
+
<CardTitle>Profile</CardTitle>
|
|
19
|
+
</CardHeader>
|
|
20
|
+
<CardContent>
|
|
21
|
+
<div className="flex items-center gap-4">
|
|
22
|
+
<Avatar className="h-20 w-20">
|
|
23
|
+
<AvatarImage src={user.image || undefined} alt={user.name} />
|
|
24
|
+
<AvatarFallback className="text-2xl">
|
|
25
|
+
{getUserInitials(user.name)}
|
|
26
|
+
</AvatarFallback>
|
|
27
|
+
</Avatar>
|
|
28
|
+
|
|
29
|
+
<div className="flex-1">
|
|
30
|
+
<div className="flex items-center gap-2 mb-1">
|
|
31
|
+
<h3 className="text-xl font-semibold">{user.name}</h3>
|
|
32
|
+
<Badge variant={isAdmin ? 'default' : 'secondary'}>
|
|
33
|
+
{user.role}
|
|
34
|
+
</Badge>
|
|
35
|
+
</div>
|
|
36
|
+
<p className="text-sm text-gray-600">{user.email}</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</CardContent>
|
|
40
|
+
</Card>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AlertTriangle } from "lucide-react"
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
|
3
|
+
|
|
4
|
+
export default function SectionErrorFallback({ title }: { title: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<Card>
|
|
7
|
+
<CardHeader>
|
|
8
|
+
<CardTitle>{title}</CardTitle>
|
|
9
|
+
</CardHeader>
|
|
10
|
+
<CardContent>
|
|
11
|
+
<p className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 py-4">
|
|
12
|
+
<AlertTriangle className="size-4 text-destructive shrink-0" />
|
|
13
|
+
Something went wrong loading this section. Try refreshing the page.
|
|
14
|
+
</p>
|
|
15
|
+
</CardContent>
|
|
16
|
+
</Card>
|
|
17
|
+
)
|
|
18
|
+
}
|