@xk2800/nextjs-template 0.6.1-beta.3 → 0.7.0-beta.0

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 CHANGED
@@ -99,6 +99,7 @@ bun --env-file=.env.production server/test-connection/index.ts
99
99
  7. Password reset and self-serve email verification, both via Resend-delivered React Email templates
100
100
  8. Security email alerts — new-device sign-ins and passkey add/remove, mirrored to the activity log
101
101
  9. Cookie-consent banner — "Necessary only" / "Accept all", with a "Cookie settings" link in the footer to change it later. The device fingerprint and Google One Tap only run after "Accept all"
102
+ 10. Self-serve account deletion from the dashboard Settings page (password-confirmed, cascades all user data)
102
103
 
103
104
  ## WIP
104
105
 
@@ -0,0 +1,137 @@
1
+ 'use client'
2
+
3
+ import { 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 { PasswordInput } from '../ui/password-input'
9
+ import {
10
+ AlertDialog,
11
+ AlertDialogCancel,
12
+ AlertDialogContent,
13
+ AlertDialogDescription,
14
+ AlertDialogFooter,
15
+ AlertDialogHeader,
16
+ AlertDialogTitle,
17
+ } from '../ui/alert-dialog'
18
+ import { authClient, useSession } from '../../lib/auth-client'
19
+
20
+ export default function DeleteAccountCard() {
21
+ const { data: session } = useSession()
22
+ const email = session?.user.email ?? ''
23
+ // null = still checking. Credential users confirm with their password;
24
+ // OAuth-only users have none, so they type their email instead and the
25
+ // server requires a recent sign-in (better-auth's freshAge check).
26
+ const [hasPassword, setHasPassword] = useState<boolean | null>(null)
27
+ const [open, setOpen] = useState(false)
28
+ const [password, setPassword] = useState('')
29
+ const [typedEmail, setTypedEmail] = useState('')
30
+ const [deleting, setDeleting] = useState(false)
31
+
32
+ useEffect(() => {
33
+ authClient.listAccounts().then(({ data }) => {
34
+ setHasPassword(!!data?.some((a) => a.providerId === 'credential'))
35
+ })
36
+ }, [])
37
+
38
+ const canSubmit = hasPassword ? password.length > 0 : typedEmail.trim().toLowerCase() === email.toLowerCase()
39
+
40
+ const close = (next: boolean) => {
41
+ if (deleting) return
42
+ setOpen(next)
43
+ if (!next) {
44
+ setPassword('')
45
+ setTypedEmail('')
46
+ }
47
+ }
48
+
49
+ const deleteAccount = async (e: React.FormEvent) => {
50
+ e.preventDefault()
51
+ if (!canSubmit) return
52
+ setDeleting(true)
53
+ try {
54
+ const { error } = await authClient.deleteUser(hasPassword ? { password } : {})
55
+ if (error) {
56
+ toast.error(
57
+ error.code === 'SESSION_EXPIRED'
58
+ ? 'For your security, sign out and sign back in, then try again.'
59
+ : error.message || 'Could not delete account',
60
+ )
61
+ return
62
+ }
63
+ // Full reload, not router.push: drops every cached session/client state.
64
+ window.location.href = '/'
65
+ } finally {
66
+ setDeleting(false)
67
+ }
68
+ }
69
+
70
+ return (
71
+ <>
72
+ <Card className="border-destructive/50">
73
+ <CardHeader>
74
+ <CardTitle>Delete account</CardTitle>
75
+ <CardDescription>
76
+ Permanently delete your account, sessions, passkeys, and activity
77
+ history. This can&apos;t be undone.
78
+ </CardDescription>
79
+ </CardHeader>
80
+ <CardContent>
81
+ <Button variant="destructive" onClick={() => setOpen(true)} disabled={hasPassword === null}>
82
+ Delete account
83
+ </Button>
84
+ </CardContent>
85
+ </Card>
86
+
87
+ <AlertDialog open={open} onOpenChange={close}>
88
+ <AlertDialogContent>
89
+ <form onSubmit={deleteAccount} className="space-y-4">
90
+ <AlertDialogHeader>
91
+ <AlertDialogTitle>Delete your account?</AlertDialogTitle>
92
+ <AlertDialogDescription>
93
+ Everything tied to <strong>{email}</strong> is removed immediately
94
+ and you&apos;ll be signed out on every device.
95
+ </AlertDialogDescription>
96
+ </AlertDialogHeader>
97
+
98
+ {hasPassword ? (
99
+ <div className="flex flex-col gap-1">
100
+ <label htmlFor="delete-password" className="text-sm font-medium">
101
+ Enter your password to confirm
102
+ </label>
103
+ <PasswordInput
104
+ id="delete-password"
105
+ autoComplete="current-password"
106
+ value={password}
107
+ onChange={(e) => setPassword(e.target.value)}
108
+ autoFocus
109
+ />
110
+ </div>
111
+ ) : (
112
+ <div className="flex flex-col gap-1">
113
+ <label htmlFor="delete-email" className="text-sm font-medium">
114
+ Type <strong>{email}</strong> to confirm
115
+ </label>
116
+ <Input
117
+ id="delete-email"
118
+ autoComplete="off"
119
+ value={typedEmail}
120
+ onChange={(e) => setTypedEmail(e.target.value)}
121
+ autoFocus
122
+ />
123
+ </div>
124
+ )}
125
+
126
+ <AlertDialogFooter>
127
+ <AlertDialogCancel type="button" disabled={deleting}>Cancel</AlertDialogCancel>
128
+ <Button type="submit" variant="destructive" disabled={!canSubmit || deleting}>
129
+ {deleting ? 'Deleting...' : 'Delete account'}
130
+ </Button>
131
+ </AlertDialogFooter>
132
+ </form>
133
+ </AlertDialogContent>
134
+ </AlertDialog>
135
+ </>
136
+ )
137
+ }
@@ -1,4 +1,4 @@
1
- import { A as ActivityActionEnum } from '../schema-BghJ8N4X.mjs';
1
+ import { A as ActivityActionEnum } from '../schema-BrBmQFkn.mjs';
2
2
  import 'drizzle-orm';
3
3
  import 'drizzle-orm/pg-core';
4
4
 
@@ -1,4 +1,4 @@
1
- import { A as ActivityActionEnum } from '../schema-BghJ8N4X.js';
1
+ import { A as ActivityActionEnum } from '../schema-BrBmQFkn.js';
2
2
  import 'drizzle-orm';
3
3
  import 'drizzle-orm/pg-core';
4
4
 
@@ -787,7 +787,13 @@ function createAuth(overrides = {}) {
787
787
  input: false
788
788
  // Prevent users from setting their own role
789
789
  }
790
- }
790
+ },
791
+ // Self-service "Delete account" (dashboard Settings → Danger zone).
792
+ // Credential users confirm with their password; OAuth-only users have
793
+ // no password, so better-auth instead requires a session younger than
794
+ // `freshAge` (default 1 day). Every user-owned table cascades on delete.
795
+ // Impersonation and last-admin guards live in hooks.before below.
796
+ deleteUser: { enabled: true }
791
797
  },
792
798
  // Advanced options
793
799
  advanced: {
@@ -891,6 +897,20 @@ function createAuth(overrides = {}) {
891
897
  }
892
898
  return;
893
899
  }
900
+ if (ctx.path === "/delete-user") {
901
+ const current2 = await api.getSessionFromCtx(ctx, { disableCookieCache: true });
902
+ if (!current2) return;
903
+ if (current2.session.impersonatedBy) {
904
+ throw new api.APIError("FORBIDDEN", { message: "Stop impersonating before deleting an account." });
905
+ }
906
+ if (current2.user.role === "admin") {
907
+ const [{ count }] = await db.select({ count: drizzleOrm.sql`count(*)` }).from(users).where(drizzleOrm.eq(users.role, "admin"));
908
+ if (Number(count) <= 1) {
909
+ throw new api.APIError("FORBIDDEN", { message: "You're the only admin. Promote another user to admin first." });
910
+ }
911
+ }
912
+ return;
913
+ }
894
914
  if (ctx.path !== "/admin/stop-impersonating") return;
895
915
  const current = await api.getSessionFromCtx(ctx, { disableCookieCache: true });
896
916
  if (current == null ? void 0 : current.session.impersonatedBy) {