feat(admin-auth): support username login and add dashboard logout
This commit is contained in:
@@ -2,8 +2,10 @@ import {
|
|||||||
authRouteHandlers,
|
authRouteHandlers,
|
||||||
canUserSelfRegister,
|
canUserSelfRegister,
|
||||||
ensureSupportUserBootstrap,
|
ensureSupportUserBootstrap,
|
||||||
|
ensureUserUsername,
|
||||||
hasOwnerUser,
|
hasOwnerUser,
|
||||||
promoteFirstRegisteredUserToOwner,
|
promoteFirstRegisteredUserToOwner,
|
||||||
|
resolveEmailFromLoginIdentifier,
|
||||||
} from "@/lib/auth/server"
|
} from "@/lib/auth/server"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
@@ -12,6 +14,9 @@ type AuthPostResponse = {
|
|||||||
user?: {
|
user?: {
|
||||||
id?: string
|
id?: string
|
||||||
role?: string
|
role?: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
username?: string
|
||||||
}
|
}
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
@@ -20,9 +25,54 @@ function jsonResponse(payload: unknown, status: number): Response {
|
|||||||
return Response.json(payload, { status })
|
return Response.json(payload, { status })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function parseJsonBody(request: Request): Promise<Record<string, unknown> | null> {
|
||||||
|
return (await request.json().catch(() => null)) as Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJsonRequest(request: Request, body: Record<string, unknown>): Request {
|
||||||
|
const headers = new Headers(request.headers)
|
||||||
|
headers.set("content-type", "application/json")
|
||||||
|
|
||||||
|
return new Request(request.url, {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignInPost(request: Request): Promise<Response> {
|
||||||
|
await ensureSupportUserBootstrap()
|
||||||
|
|
||||||
|
const body = await parseJsonBody(request)
|
||||||
|
const identifier = typeof body?.identifier === "string" ? body.identifier : null
|
||||||
|
const rawEmail = typeof body?.email === "string" ? body.email : null
|
||||||
|
const resolvedEmail = await resolveEmailFromLoginIdentifier(identifier ?? rawEmail)
|
||||||
|
|
||||||
|
if (!resolvedEmail) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
message: "Invalid email or username.",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewrittenBody = {
|
||||||
|
...(body ?? {}),
|
||||||
|
email: resolvedEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
return authRouteHandlers.POST(buildJsonRequest(request, rewrittenBody))
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSignUpPost(request: Request): Promise<Response> {
|
async function handleSignUpPost(request: Request): Promise<Response> {
|
||||||
await ensureSupportUserBootstrap()
|
await ensureSupportUserBootstrap()
|
||||||
|
|
||||||
|
const signUpBody = await parseJsonBody(request)
|
||||||
|
const preferredUsername =
|
||||||
|
typeof signUpBody?.username === "string" ? signUpBody.username : undefined
|
||||||
|
const { username: _ignoredUsername, ...signUpBodyWithoutUsername } = signUpBody ?? {}
|
||||||
|
|
||||||
const hadOwnerBeforeSignUp = await hasOwnerUser()
|
const hadOwnerBeforeSignUp = await hasOwnerUser()
|
||||||
const registrationEnabled = await canUserSelfRegister()
|
const registrationEnabled = await canUserSelfRegister()
|
||||||
|
|
||||||
@@ -35,7 +85,11 @@ async function handleSignUpPost(request: Request): Promise<Response> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await authRouteHandlers.POST(request)
|
const response = await authRouteHandlers.POST(
|
||||||
|
buildJsonRequest(request, {
|
||||||
|
...signUpBodyWithoutUsername,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return response
|
return response
|
||||||
@@ -51,6 +105,12 @@ async function handleSignUpPost(request: Request): Promise<Response> {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureUserUsername(userId, {
|
||||||
|
preferred: preferredUsername,
|
||||||
|
fallbackEmail: payload?.user?.email,
|
||||||
|
fallbackName: payload?.user?.name,
|
||||||
|
})
|
||||||
|
|
||||||
if (hadOwnerBeforeSignUp || !payload?.user) {
|
if (hadOwnerBeforeSignUp || !payload?.user) {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
@@ -82,6 +142,10 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
export async function POST(request: Request): Promise<Response> {
|
export async function POST(request: Request): Promise<Response> {
|
||||||
const pathname = new URL(request.url).pathname
|
const pathname = new URL(request.url).pathname
|
||||||
|
|
||||||
|
if (pathname.endsWith("/sign-in/email")) {
|
||||||
|
return handleSignInPost(request)
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.endsWith("/sign-up/email")) {
|
if (pathname.endsWith("/sign-up/email")) {
|
||||||
return handleSignUpPost(request)
|
return handleSignUpPost(request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
|
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
|
||||||
|
|
||||||
const [name, setName] = useState("Admin User")
|
const [name, setName] = useState("Admin User")
|
||||||
|
const [username, setUsername] = useState("")
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [isBusy, setIsBusy] = useState(false)
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
@@ -50,7 +51,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
identifier: email,
|
||||||
password,
|
password,
|
||||||
callbackURL: nextPath,
|
callbackURL: nextPath,
|
||||||
}),
|
}),
|
||||||
@@ -93,6 +94,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
|
username,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackURL: nextPath,
|
callbackURL: nextPath,
|
||||||
@@ -152,11 +154,11 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium" htmlFor="email">
|
<label className="text-sm font-medium" htmlFor="email">
|
||||||
Email
|
Email or username
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="text"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
@@ -228,6 +230,19 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium" htmlFor="username">
|
||||||
|
Username (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium" htmlFor="password">
|
<label className="text-sm font-medium" htmlFor="password">
|
||||||
Password
|
Password
|
||||||
|
|||||||
36
apps/admin/src/app/logout-button.tsx
Normal file
36
apps/admin/src/app/logout-button.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@cms/ui/button"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
export function LogoutButton() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
setIsBusy(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/sign-out", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ callbackURL: "/login" }),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: Temporary cookie fallback until role resolution no longer needs this cookie.
|
||||||
|
document.cookie = "cms_role=; Path=/; Max-Age=0; SameSite=Lax"
|
||||||
|
router.push("/login")
|
||||||
|
router.refresh()
|
||||||
|
setIsBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="button" onClick={() => void handleLogout()} disabled={isBusy} variant="secondary">
|
||||||
|
{isBusy ? "Signing out..." : "Sign out"}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import Link from "next/link"
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||||
|
import { LogoutButton } from "./logout-button"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -28,13 +29,14 @@ export default async function AdminHomePage() {
|
|||||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
||||||
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
|
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
|
||||||
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
|
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
|
||||||
<div className="pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<Link
|
<Link
|
||||||
href="/todo"
|
href="/todo"
|
||||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
Open roadmap and progress
|
Open roadmap and progress
|
||||||
</Link>
|
</Link>
|
||||||
|
<LogoutButton />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const DEFAULT_SUPPORT_USERNAME = "support"
|
|||||||
const DEFAULT_SUPPORT_PASSWORD = "change-me-support-password"
|
const DEFAULT_SUPPORT_PASSWORD = "change-me-support-password"
|
||||||
const DEFAULT_SUPPORT_NAME = "Technical Support"
|
const DEFAULT_SUPPORT_NAME = "Technical Support"
|
||||||
const DEFAULT_SUPPORT_LOGIN_KEY = "support-access"
|
const DEFAULT_SUPPORT_LOGIN_KEY = "support-access"
|
||||||
|
const USERNAME_MAX_LENGTH = 32
|
||||||
|
|
||||||
function resolveAuthSecret(): string {
|
function resolveAuthSecret(): string {
|
||||||
const value = process.env.BETTER_AUTH_SECRET
|
const value = process.env.BETTER_AUTH_SECRET
|
||||||
@@ -88,6 +89,116 @@ function resolveBootstrapValue(
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUsernameCandidate(input: string | null | undefined): string | null {
|
||||||
|
if (!input) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, "-")
|
||||||
|
.replace(/^[._-]+|[._-]+$/g, "")
|
||||||
|
.slice(0, USERNAME_MAX_LENGTH)
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEmailLocalPart(email: string): string {
|
||||||
|
return email.split("@")[0] ?? email
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailableUsername(base: string): Promise<string> {
|
||||||
|
const normalizedBase = normalizeUsernameCandidate(base) ?? "user"
|
||||||
|
|
||||||
|
for (let suffix = 0; suffix < 1000; suffix += 1) {
|
||||||
|
const candidate =
|
||||||
|
suffix === 0 ? normalizedBase : `${normalizedBase}-${suffix}`.slice(0, USERNAME_MAX_LENGTH)
|
||||||
|
const existing = await db.user.findUnique({
|
||||||
|
where: { username: candidate },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to allocate unique username")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureUserUsername(
|
||||||
|
userId: string,
|
||||||
|
options: {
|
||||||
|
preferred?: string | null | undefined
|
||||||
|
fallbackEmail?: string | null | undefined
|
||||||
|
fallbackName?: string | null | undefined
|
||||||
|
} = {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, username: true, email: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.username) {
|
||||||
|
return user.username
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseCandidate =
|
||||||
|
normalizeUsernameCandidate(options.preferred) ??
|
||||||
|
normalizeUsernameCandidate(
|
||||||
|
options.fallbackEmail ? extractEmailLocalPart(options.fallbackEmail) : null,
|
||||||
|
) ??
|
||||||
|
normalizeUsernameCandidate(options.fallbackName) ??
|
||||||
|
normalizeUsernameCandidate(extractEmailLocalPart(user.email)) ??
|
||||||
|
normalizeUsernameCandidate(user.name) ??
|
||||||
|
"user"
|
||||||
|
|
||||||
|
const username = await getAvailableUsername(baseCandidate)
|
||||||
|
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { username },
|
||||||
|
})
|
||||||
|
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveEmailFromLoginIdentifier(
|
||||||
|
identifier: string | null | undefined,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const value = identifier?.trim()
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes("@")) {
|
||||||
|
return value.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = normalizeUsernameCandidate(value)
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { username },
|
||||||
|
select: { email: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return user?.email ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: "CMS Admin",
|
appName: "CMS Admin",
|
||||||
baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin,
|
baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin,
|
||||||
@@ -110,6 +221,11 @@ export const auth = betterAuth({
|
|||||||
defaultValue: "editor",
|
defaultValue: "editor",
|
||||||
input: false,
|
input: false,
|
||||||
},
|
},
|
||||||
|
username: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
input: false,
|
||||||
|
},
|
||||||
isBanned: {
|
isBanned: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
required: true,
|
required: true,
|
||||||
@@ -146,6 +262,7 @@ let supportBootstrapPromise: Promise<void> | null = null
|
|||||||
|
|
||||||
type BootstrapUserConfig = {
|
type BootstrapUserConfig = {
|
||||||
email: string
|
email: string
|
||||||
|
username: string
|
||||||
name: string
|
name: string
|
||||||
password: string
|
password: string
|
||||||
role: Role
|
role: Role
|
||||||
@@ -187,13 +304,21 @@ async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureUserUsername(existing.user.id, {
|
||||||
|
preferred: config.username,
|
||||||
|
fallbackEmail: existing.user.email,
|
||||||
|
fallbackName: config.name,
|
||||||
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availableUsername = await getAvailableUsername(config.username)
|
||||||
const passwordHash = await ctx.password.hash(config.password)
|
const passwordHash = await ctx.password.hash(config.password)
|
||||||
const createdUser = await ctx.internalAdapter.createUser({
|
const createdUser = await ctx.internalAdapter.createUser({
|
||||||
name: config.name,
|
name: config.name,
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
|
username: availableUsername,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
role: config.role,
|
role: config.role,
|
||||||
isBanned: false,
|
isBanned: false,
|
||||||
@@ -220,6 +345,7 @@ async function bootstrapSystemUsers(): Promise<void> {
|
|||||||
|
|
||||||
await ensureCredentialUser({
|
await ensureCredentialUser({
|
||||||
email: supportEmail,
|
email: supportEmail,
|
||||||
|
username: supportUsername,
|
||||||
name: supportName,
|
name: supportName,
|
||||||
password: supportPassword,
|
password: supportPassword,
|
||||||
role: "support",
|
role: "support",
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ bun run db:migrate
|
|||||||
bun run db:seed
|
bun run db:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Create a named migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run db:migrate:named -- --name your_migration_name
|
||||||
|
```
|
||||||
|
|
||||||
Reset local dev DB:
|
Reset local dev DB:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -30,8 +30,9 @@
|
|||||||
"check": "biome check .",
|
"check": "biome check .",
|
||||||
"db:generate": "bun --filter @cms/db db:generate",
|
"db:generate": "bun --filter @cms/db db:generate",
|
||||||
"db:migrate": "bun --filter @cms/db db:migrate",
|
"db:migrate": "bun --filter @cms/db db:migrate",
|
||||||
"db:migrate:named": "bun --filter @cms/db db:migrate:named",
|
"db:migrate:named": "cd packages/db && bun --env-file=../../.env prisma migrate dev",
|
||||||
"db:reset:dev": "bun --filter @cms/db db:reset:dev && bun run db:generate && bun run db:seed",
|
"db:migrate:deploy": "bun --filter @cms/db db:migrate:deploy",
|
||||||
|
"db:reset:dev": "bun --filter @cms/db db:reset:dev && bun run auth:seed:support",
|
||||||
"db:push": "bun --filter @cms/db db:push",
|
"db:push": "bun --filter @cms/db db:push",
|
||||||
"db:studio": "bun --filter @cms/db db:studio",
|
"db:studio": "bun --filter @cms/db db:studio",
|
||||||
"db:seed": "bun --filter @cms/db db:seed && bun --filter @cms/admin auth:seed:support",
|
"db:seed": "bun --filter @cms/db db:seed && bun --filter @cms/admin auth:seed:support",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"db:generate": "bun --env-file=../../.env prisma generate",
|
"db:generate": "bun --env-file=../../.env prisma generate",
|
||||||
"db:migrate": "bun --env-file=../../.env prisma migrate dev --name init",
|
"db:migrate": "bun --env-file=../../.env prisma migrate dev --name init",
|
||||||
"db:migrate:named": "bun --env-file=../../.env prisma migrate dev",
|
"db:migrate:named": "bun --env-file=../../.env prisma migrate dev",
|
||||||
"db:reset:dev": "bun --env-file=../../.env prisma migrate reset --force --skip-generate --skip-seed",
|
"db:migrate:deploy": "bun --env-file=../../.env prisma migrate deploy",
|
||||||
|
"db:reset:dev": "bun --env-file=../../.env prisma migrate reset --force",
|
||||||
"db:push": "bun --env-file=../../.env prisma db push",
|
"db:push": "bun --env-file=../../.env prisma db push",
|
||||||
"db:studio": "bun --env-file=../../.env prisma studio",
|
"db:studio": "bun --env-file=../../.env prisma studio",
|
||||||
"db:seed": "bun --env-file=../../.env prisma/seed.ts"
|
"db:seed": "bun --env-file=../../.env prisma/seed.ts"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "username" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||||
@@ -22,6 +22,7 @@ model User {
|
|||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
email String
|
email String
|
||||||
|
username String? @unique
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
Reference in New Issue
Block a user