upd: auth
Deskripsi: -update login - update struktur database No Issues
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The values [USER,SUPER_ADMIN] on the enum `Role` will be removed. If these variants are still used in the database, this will fail.
|
||||||
|
- You are about to drop the column `app` on the `bug` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterEnum
|
||||||
|
BEGIN;
|
||||||
|
CREATE TYPE "Role_new" AS ENUM ('ADMIN', 'DEVELOPER');
|
||||||
|
ALTER TABLE "public"."user" ALTER COLUMN "role" DROP DEFAULT;
|
||||||
|
ALTER TABLE "user" ALTER COLUMN "role" TYPE "Role_new" USING ("role"::text::"Role_new");
|
||||||
|
ALTER TYPE "Role" RENAME TO "Role_old";
|
||||||
|
ALTER TYPE "Role_new" RENAME TO "Role";
|
||||||
|
DROP TYPE "public"."Role_old";
|
||||||
|
ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'ADMIN';
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "bug" DROP COLUMN "app",
|
||||||
|
ADD COLUMN "appId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'ADMIN';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "App" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"version" TEXT NOT NULL,
|
||||||
|
"minVersion" TEXT NOT NULL,
|
||||||
|
"maintenance" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "App_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "bug" ADD CONSTRAINT "bug_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "App" ALTER COLUMN "version" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "minVersion" DROP NOT NULL;
|
||||||
@@ -9,9 +9,7 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
USER
|
|
||||||
ADMIN
|
ADMIN
|
||||||
SUPER_ADMIN
|
|
||||||
DEVELOPER
|
DEVELOPER
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +42,7 @@ model User {
|
|||||||
name String
|
name String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
role Role @default(USER)
|
role Role @default(ADMIN)
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -71,6 +69,19 @@ model Session {
|
|||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model App {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
version String?
|
||||||
|
minVersion String?
|
||||||
|
maintenance Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
bugs Bug[]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
model Log {
|
model Log {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
@@ -86,12 +97,12 @@ model Log {
|
|||||||
model Bug {
|
model Bug {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String?
|
userId String?
|
||||||
app String?
|
appId String?
|
||||||
affectedVersion String
|
affectedVersion String
|
||||||
device String
|
device String
|
||||||
os String
|
os String
|
||||||
status BugStatus
|
status BugStatus
|
||||||
source BugSource
|
source BugSource
|
||||||
description String
|
description String
|
||||||
stackTrace String?
|
stackTrace String?
|
||||||
fixedVersion String?
|
fixedVersion String?
|
||||||
@@ -100,6 +111,7 @@ model Bug {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
app App? @relation(fields: [appId], references: [id])
|
||||||
|
|
||||||
images BugImage[]
|
images BugImage[]
|
||||||
logs BugLog[]
|
logs BugLog[]
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ const SUPER_ADMIN_EMAILS = (process.env.SUPER_ADMIN_EMAIL ?? '').split(',').map(
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const users = [
|
const users = [
|
||||||
{ name: 'Super Admin', email: 'superadmin@example.com', password: 'superadmin123', role: 'SUPER_ADMIN' as const },
|
|
||||||
{ name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const },
|
{ name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const },
|
||||||
{ name: 'User', email: 'user@example.com', password: 'user123', role: 'USER' as const },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
@@ -21,13 +19,28 @@ async function main() {
|
|||||||
console.log(`Seeded: ${u.email} (${u.role})`)
|
console.log(`Seeded: ${u.email} (${u.role})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote super admin emails from env
|
// Promote DEVELOPER emails from env
|
||||||
for (const email of SUPER_ADMIN_EMAILS) {
|
for (const email of SUPER_ADMIN_EMAILS) {
|
||||||
const user = await prisma.user.findUnique({ where: { email } })
|
const password = await Bun.password.hash('developer123', { algorithm: 'bcrypt' })
|
||||||
if (user && user.role !== 'SUPER_ADMIN') {
|
await prisma.user.upsert({
|
||||||
await prisma.user.update({ where: { email }, data: { role: 'SUPER_ADMIN' } })
|
where: { email },
|
||||||
console.log(`Promoted to SUPER_ADMIN: ${email}`)
|
update: { role: 'DEVELOPER', password },
|
||||||
}
|
create: { name: email.split('@')[0].toUpperCase(), email, password, role: 'DEVELOPER' },
|
||||||
|
})
|
||||||
|
console.log(`Promoted to DEVELOPER: ${email}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = [
|
||||||
|
{ id: 'desa-plus', name: 'Desa+' },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const a of apps) {
|
||||||
|
await prisma.app.upsert({
|
||||||
|
where: { id: a.id },
|
||||||
|
update: { name: a.name },
|
||||||
|
create: { id: a.id, name: a.name },
|
||||||
|
})
|
||||||
|
console.log(`Seeded: ${a.name}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
171
src/app.ts
171
src/app.ts
@@ -37,8 +37,8 @@ export function createApp() {
|
|||||||
return { error: 'Email atau password salah' }
|
return { error: 'Email atau password salah' }
|
||||||
}
|
}
|
||||||
// Auto-promote super admin from env
|
// Auto-promote super admin from env
|
||||||
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') {
|
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
|
||||||
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } })
|
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
|
||||||
}
|
}
|
||||||
const token = crypto.randomUUID()
|
const token = crypto.randomUUID()
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
|
||||||
@@ -78,80 +78,7 @@ export function createApp() {
|
|||||||
return { user: session.user }
|
return { user: session.user }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Google OAuth ──────────────────────────────────
|
|
||||||
.get('/api/auth/google', ({ request, set }) => {
|
|
||||||
const origin = new URL(request.url).origin
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: env.GOOGLE_CLIENT_ID,
|
|
||||||
redirect_uri: `${origin}/api/auth/callback/google`,
|
|
||||||
response_type: 'code',
|
|
||||||
scope: 'openid email profile',
|
|
||||||
access_type: 'offline',
|
|
||||||
prompt: 'consent',
|
|
||||||
})
|
|
||||||
set.status = 302; set.headers['location'] = `https://accounts.google.com/o/oauth2/v2/auth?${params}`
|
|
||||||
})
|
|
||||||
|
|
||||||
.get('/api/auth/callback/google', async ({ request, set }) => {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
const code = url.searchParams.get('code')
|
|
||||||
const origin = url.origin
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
set.status = 302; set.headers['location'] = '/login?error=google_failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange code for tokens
|
|
||||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
code,
|
|
||||||
client_id: env.GOOGLE_CLIENT_ID,
|
|
||||||
client_secret: env.GOOGLE_CLIENT_SECRET,
|
|
||||||
redirect_uri: `${origin}/api/auth/callback/google`,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!tokenRes.ok) {
|
|
||||||
set.status = 302; set.headers['location'] = '/login?error=google_failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = (await tokenRes.json()) as { access_token: string }
|
|
||||||
|
|
||||||
// Get user info
|
|
||||||
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
||||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!userInfoRes.ok) {
|
|
||||||
set.status = 302; set.headers['location'] = '/login?error=google_failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const googleUser = (await userInfoRes.json()) as { email: string; name: string }
|
|
||||||
|
|
||||||
// Upsert user (no password for Google users)
|
|
||||||
const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email)
|
|
||||||
const user = await prisma.user.upsert({
|
|
||||||
where: { email: googleUser.email },
|
|
||||||
update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) },
|
|
||||||
create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create session
|
|
||||||
const token = crypto.randomUUID()
|
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
|
||||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
|
||||||
|
|
||||||
await createSystemLog(user.id, 'LOGIN', 'Logged in via Google')
|
|
||||||
|
|
||||||
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
|
||||||
set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile'
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Monitoring API ────────────────────────────────
|
// ─── Monitoring API ────────────────────────────────
|
||||||
.get('/api/dashboard/stats', async () => {
|
.get('/api/dashboard/stats', async () => {
|
||||||
@@ -172,7 +99,7 @@ export function createApp() {
|
|||||||
})
|
})
|
||||||
return bugs.map(b => ({
|
return bugs.map(b => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
app: b.app,
|
app: b.appId,
|
||||||
message: b.description,
|
message: b.description,
|
||||||
version: b.affectedVersion,
|
version: b.affectedVersion,
|
||||||
time: b.createdAt.toISOString(),
|
time: b.createdAt.toISOString(),
|
||||||
@@ -180,18 +107,56 @@ export function createApp() {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
.get('/api/apps', async () => {
|
.get('/api/apps', async ({ query }) => {
|
||||||
const desaPlusErrors = await prisma.bug.count({ where: { app: { in: ['desa-plus', 'desa_plus'] }, status: 'OPEN' } })
|
const search = (query.search as string) || ''
|
||||||
return [
|
const where: any = {}
|
||||||
{ id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: desaPlusErrors, version: '2.4.1' },
|
if (search) {
|
||||||
]
|
where.name = { contains: search, mode: 'insensitive' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = await prisma.app.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
_count: { select: { bugs: true } },
|
||||||
|
bugs: { where: { status: 'OPEN' }, select: { id: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return apps.map((app) => ({
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active',
|
||||||
|
errors: app.bugs.length,
|
||||||
|
version: app.version ?? '-',
|
||||||
|
maintenance: app.maintenance,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
.get('/api/apps/:appId', ({ params: { appId } }) => {
|
.get('/api/apps/:appId', async ({ params: { appId }, set }) => {
|
||||||
const apps = {
|
const app = await prisma.app.findUnique({
|
||||||
'desa-plus': { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' },
|
where: { id: appId },
|
||||||
|
include: {
|
||||||
|
_count: { select: { bugs: true } },
|
||||||
|
bugs: { where: { status: 'OPEN' }, select: { id: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
set.status = 404
|
||||||
|
return { error: 'App not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active',
|
||||||
|
errors: app.bugs.length,
|
||||||
|
version: app.version ?? '-',
|
||||||
|
minVersion: app.minVersion,
|
||||||
|
maintenance: app.maintenance,
|
||||||
|
totalBugs: app._count.bugs,
|
||||||
}
|
}
|
||||||
return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
.get('/api/logs', async ({ query }) => {
|
.get('/api/logs', async ({ query }) => {
|
||||||
@@ -246,7 +211,7 @@ export function createApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json()) as { type: string, message: string }
|
const body = (await request.json()) as { type: string, message: string }
|
||||||
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }))?.id || ''
|
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || ''
|
||||||
|
|
||||||
await createSystemLog(actingUserId, body.type as any, body.message)
|
await createSystemLog(actingUserId, body.type as any, body.message)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
@@ -419,7 +384,7 @@ export function createApp() {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (app && app !== 'all') {
|
if (app && app !== 'all') {
|
||||||
where.app = app
|
where.appId = app
|
||||||
}
|
}
|
||||||
if (status && status !== 'all') {
|
if (status && status !== 'all') {
|
||||||
where.status = status
|
where.status = status
|
||||||
@@ -463,12 +428,12 @@ export function createApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json()) as any
|
const body = (await request.json()) as any
|
||||||
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
|
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||||
const actingUserId = userId || defaultAdmin?.id || ''
|
const actingUserId = userId || defaultAdmin?.id || ''
|
||||||
|
|
||||||
const bug = await prisma.bug.create({
|
const bug = await prisma.bug.create({
|
||||||
data: {
|
data: {
|
||||||
app: body.app,
|
appId: body.app,
|
||||||
affectedVersion: body.affectedVersion,
|
affectedVersion: body.affectedVersion,
|
||||||
device: body.device,
|
device: body.device,
|
||||||
os: body.os,
|
os: body.os,
|
||||||
@@ -508,7 +473,7 @@ export function createApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json()) as { feedBack: string }
|
const body = (await request.json()) as { feedBack: string }
|
||||||
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
|
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||||
const actingUserId = userId || defaultAdmin?.id || undefined
|
const actingUserId = userId || defaultAdmin?.id || undefined
|
||||||
|
|
||||||
const bug = await prisma.bug.update({
|
const bug = await prisma.bug.update({
|
||||||
@@ -538,7 +503,7 @@ export function createApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json()) as { status: string; description?: string }
|
const body = (await request.json()) as { status: string; description?: string }
|
||||||
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } })
|
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||||
const actingUserId = userId || defaultAdmin?.id || undefined
|
const actingUserId = userId || defaultAdmin?.id || undefined
|
||||||
|
|
||||||
const bug = await prisma.bug.update({
|
const bug = await prisma.bug.update({
|
||||||
@@ -562,6 +527,30 @@ export function createApp() {
|
|||||||
return bug
|
return bug
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── System Status API ─────────────────────────────
|
||||||
|
.get('/api/system/status', async () => {
|
||||||
|
try {
|
||||||
|
// Check database connectivity
|
||||||
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
const activeSessions = await prisma.session.count({
|
||||||
|
where: { expiresAt: { gte: new Date() } },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
status: 'operational',
|
||||||
|
database: 'connected',
|
||||||
|
activeSessions,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
status: 'degraded',
|
||||||
|
database: 'disconnected',
|
||||||
|
activeSessions: 0,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Example API ───────────────────────────────────
|
// ─── Example API ───────────────────────────────────
|
||||||
.get('/api/hello', () => ({
|
.get('/api/hello', () => ({
|
||||||
message: 'Hello, world!',
|
message: 'Hello, world!',
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box, useComputedColorScheme } from '@mantine/core'
|
import { Avatar, Button, Card, Group, Stack, Text, useComputedColorScheme } from '@mantine/core'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb'
|
import { TbChevronRight, TbDeviceMobile } from 'react-icons/tb'
|
||||||
|
|
||||||
interface AppCardProps {
|
interface AppCardProps {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
status: 'active' | 'warning' | 'error'
|
status: 'active' | 'warning' | 'error'
|
||||||
users: number
|
users?: number
|
||||||
errors: number
|
errors: number
|
||||||
version: string
|
version: string
|
||||||
|
maintenance?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppCard({ id, name, status, users, errors, version }: AppCardProps) {
|
export function AppCard({ id, name, status, errors, version }: AppCardProps) {
|
||||||
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
|
const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red'
|
||||||
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||||
|
|
||||||
@@ -46,12 +47,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
|
<Text fw={700} size="lg" style={{ letterSpacing: '-0.3px' }}>{name}</Text>
|
||||||
<Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text>
|
{/* <Text size="xs" c="dimmed" fw={600}>VERSION {version}</Text> */}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Badge color={statusColor} variant="dot" size="sm">
|
{/* <Badge color={statusColor} variant="dot" size="sm">
|
||||||
{status.toUpperCase()}
|
{status.toUpperCase()}
|
||||||
</Badge>
|
</Badge> */}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* <Stack gap="md" mt="sm">
|
{/* <Stack gap="md" mt="sm">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
||||||
|
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
Burger,
|
Burger,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Menu,
|
Menu,
|
||||||
NavLink,
|
NavLink,
|
||||||
Select,
|
Select,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
useMantineColorScheme
|
useMantineColorScheme
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDisclosure } from '@mantine/hooks'
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
|
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
TbAlertTriangle,
|
TbAlertTriangle,
|
||||||
@@ -50,6 +53,26 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
const matches = useMatches()
|
const matches = useMatches()
|
||||||
const currentPath = matches[matches.length - 1]?.pathname
|
const currentPath = matches[matches.length - 1]?.pathname
|
||||||
|
|
||||||
|
// ─── Connect to auth system ──────────────────────────
|
||||||
|
const { data: sessionData } = useSession()
|
||||||
|
const user = sessionData?.user
|
||||||
|
const logout = useLogout()
|
||||||
|
|
||||||
|
// ─── Fetch registered apps from database ─────────────
|
||||||
|
const { data: appsData } = useQuery({
|
||||||
|
queryKey: ['apps'],
|
||||||
|
queryFn: () => fetch('/api/apps', { credentials: 'include' }).then((r) => r.json()),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Fetch system status from database ───────────────
|
||||||
|
const { data: systemStatus } = useQuery({
|
||||||
|
queryKey: ['system', 'status'],
|
||||||
|
queryFn: () => fetch('/api/system/status', { credentials: 'include' }).then((r) => r.json()),
|
||||||
|
refetchInterval: 30_000, // refresh every 30 seconds
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
const globalNav = [
|
const globalNav = [
|
||||||
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
|
{ label: 'Dashboard', icon: TbDashboard, to: '/dashboard' },
|
||||||
{ label: 'Applications', icon: TbApps, to: '/apps' },
|
{ label: 'Applications', icon: TbApps, to: '/apps' },
|
||||||
@@ -61,6 +84,21 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
const activeApp = appId ? APP_CONFIGS[appId] : null
|
const activeApp = appId ? APP_CONFIGS[appId] : null
|
||||||
const navLinks = activeApp ? activeApp.menus : globalNav
|
const navLinks = activeApp ? activeApp.menus : globalNav
|
||||||
|
|
||||||
|
// Build app selector data from API
|
||||||
|
const appSelectData = (appsData || []).map((app: any) => ({
|
||||||
|
value: app.id,
|
||||||
|
label: app.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// System status indicator
|
||||||
|
const isOperational = systemStatus?.status === 'operational'
|
||||||
|
const statusColor = isOperational ? '#10b981' : '#f59e0b'
|
||||||
|
const statusText = isOperational ? 'All Systems Operational' : 'System Degraded'
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 70 }}
|
header={{ height: 70 }}
|
||||||
@@ -115,21 +153,47 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={undefined}
|
src={undefined}
|
||||||
alt="User"
|
alt={user?.name || 'User'}
|
||||||
color="brand-blue"
|
color="brand-blue"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
>
|
||||||
|
{user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<Menu.Label>
|
||||||
|
<Text size="sm" fw={600} truncate>{user.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>{user.email}</Text>
|
||||||
|
</Menu.Label>
|
||||||
|
<Menu.Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Menu.Label>Application</Menu.Label>
|
<Menu.Label>Application</Menu.Label>
|
||||||
<Menu.Item leftSection={<TbUserCircle size={16} />}>Profile</Menu.Item>
|
<Menu.Item
|
||||||
<Menu.Item leftSection={<TbSettings size={16} />}>Settings</Menu.Item>
|
leftSection={<TbUserCircle size={16} />}
|
||||||
|
onClick={() => navigate({ to: '/profile' })}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<TbSettings size={16} />}
|
||||||
|
onClick={() => navigate({ to: '/dashboard' })}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Label>Danger Zone</Menu.Label>
|
<Menu.Label>Danger Zone</Menu.Label>
|
||||||
<Menu.Item color="red" leftSection={<TbLogout size={16} />}>
|
<Menu.Item
|
||||||
Logout
|
color="red"
|
||||||
|
leftSection={<TbLogout size={16} />}
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={logout.isPending}
|
||||||
|
>
|
||||||
|
{logout.isPending ? 'Logging out...' : 'Logout'}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
@@ -160,10 +224,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
<Select
|
<Select
|
||||||
label="Selected Application"
|
label="Selected Application"
|
||||||
value={appId}
|
value={appId}
|
||||||
data={[
|
data={appSelectData.length > 0 ? appSelectData : [
|
||||||
{ value: 'desa-plus', label: 'Desa+' },
|
{ value: 'desa-plus', label: 'Desa+' },
|
||||||
{ value: 'e-commerce', label: 'E-Commerce' },
|
|
||||||
{ value: 'fitness-app', label: 'Fitness App' },
|
|
||||||
]}
|
]}
|
||||||
onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })}
|
onChange={(val) => val && navigate({ to: '/apps/$appId', params: { appId: val } })}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -225,19 +287,26 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
>
|
>
|
||||||
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
|
<Text size="xs" c="dimmed" fw={600} mb="xs">SYSTEM STATUS</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981' }} />
|
<Box style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, boxShadow: `0 0 6px ${statusColor}` }} />
|
||||||
<Text size="sm" fw={500}>All Systems Operational</Text>
|
<Text size="sm" fw={500}>{statusText}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{systemStatus && (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{systemStatus.activeSessions} active session{systemStatus.activeSessions !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
fullWidth
|
fullWidth
|
||||||
leftSection={<TbLogout size={16} />}
|
leftSection={logout.isPending ? <Loader size={16} color="red" /> : <TbLogout size={16} />}
|
||||||
mt="md"
|
mt="md"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={logout.isPending}
|
||||||
>
|
>
|
||||||
Log out
|
{logout.isPending ? 'Logging out...' : 'Log out'}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
export type Role = 'USER' | 'ADMIN' | 'SUPER_ADMIN'
|
export type Role = | 'ADMIN' | 'DEVELOPER'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,12 +41,7 @@ export function useLogin() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(['auth', 'session'], data)
|
queryClient.setQueryData(['auth', 'session'], data)
|
||||||
// Super admin → dashboard, others → profile
|
navigate({ to: '/dashboard' })
|
||||||
if (data.user.role === 'SUPER_ADMIN') {
|
|
||||||
navigate({ to: '/dashboard' })
|
|
||||||
} else {
|
|
||||||
navigate({ to: '/profile' })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core'
|
import { Container, Stack, Title, Text, SimpleGrid, Group, Button, TextInput, Loader } from '@mantine/core'
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks'
|
||||||
|
import { useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { TbPlus, TbSearch } from 'react-icons/tb'
|
import { TbPlus, TbSearch } from 'react-icons/tb'
|
||||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||||
@@ -10,9 +12,12 @@ export const Route = createFileRoute('/apps/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function AppsPage() {
|
function AppsPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 300)
|
||||||
|
|
||||||
const { data: apps, isLoading } = useQuery({
|
const { data: apps, isLoading } = useQuery({
|
||||||
queryKey: ['apps'],
|
queryKey: ['apps', debouncedSearch],
|
||||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
queryFn: () => fetch(`/api/apps?search=${encodeURIComponent(debouncedSearch)}`).then((r) => r.json()),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,14 +29,14 @@ function AppsPage() {
|
|||||||
<Title order={2} className="gradient-text">Applications</Title>
|
<Title order={2} className="gradient-text">Applications</Title>
|
||||||
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
|
<Text size="sm" c="dimmed">Manage and monitor all your mobile applications from one place.</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button
|
{/* <Button
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
leftSection={<TbPlus size={18} />}
|
leftSection={<TbPlus size={18} />}
|
||||||
radius="md"
|
radius="md"
|
||||||
>
|
>
|
||||||
Add New Application
|
Add New Application
|
||||||
</Button>
|
</Button> */}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
@@ -40,6 +45,8 @@ function AppsPage() {
|
|||||||
leftSection={<TbSearch size={16} />}
|
leftSection={<TbSearch size={16} />}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const Route = createFileRoute('/dashboard')({
|
|||||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||||
})
|
})
|
||||||
if (!data?.user) throw redirect({ to: '/login' })
|
if (!data?.user) throw redirect({ to: '/login' })
|
||||||
if (data.user.role !== 'SUPER_ADMIN') throw redirect({ to: '/profile' })
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) throw redirect({ to: '/login' })
|
if (e instanceof Error) throw redirect({ to: '/login' })
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useLogin } from '@/frontend/hooks/useAuth'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -13,8 +14,7 @@ import {
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FcGoogle } from 'react-icons/fc'
|
import { FcGoogle } from 'react-icons/fc'
|
||||||
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
|
import { TbAlertCircle, TbLock, TbLogin, TbMail } from 'react-icons/tb'
|
||||||
import { useLogin } from '@/frontend/hooks/useAuth'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/login')({
|
export const Route = createFileRoute('/login')({
|
||||||
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
|
validateSearch: (search: Record<string, unknown>): { error?: string } => ({
|
||||||
@@ -27,7 +27,7 @@ export const Route = createFileRoute('/login')({
|
|||||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||||
})
|
})
|
||||||
if (data?.user) {
|
if (data?.user) {
|
||||||
throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' })
|
throw redirect({ to: '/dashboard' })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) return
|
if (e instanceof Error) return
|
||||||
@@ -57,12 +57,6 @@ function LoginPage() {
|
|||||||
Login
|
Login
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Text c="dimmed" size="sm" ta="center">
|
|
||||||
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
|
|
||||||
<br />
|
|
||||||
or: <strong>user@example.com</strong> / <strong>user123</strong>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{(login.isError || searchError) && (
|
{(login.isError || searchError) && (
|
||||||
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
||||||
{login.isError ? login.error.message : 'Google login failed, please try again.'}
|
{login.isError ? login.error.message : 'Google login failed, please try again.'}
|
||||||
@@ -95,18 +89,6 @@ function LoginPage() {
|
|||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Divider label="or" labelPosition="center" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href="/api/auth/google"
|
|
||||||
fullWidth
|
|
||||||
variant="default"
|
|
||||||
leftSection={<FcGoogle size={18} />}
|
|
||||||
>
|
|
||||||
Login with Google
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ export const Route = createFileRoute('/profile')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const roleBadgeColor: Record<string, string> = {
|
const roleBadgeColor: Record<string, string> = {
|
||||||
USER: 'blue',
|
|
||||||
ADMIN: 'violet',
|
ADMIN: 'violet',
|
||||||
SUPER_ADMIN: 'red',
|
DEVELOPER: 'red',
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
|
|||||||
@@ -59,20 +59,15 @@ const getRoleColor = (role: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roles = [
|
const roles = [
|
||||||
{
|
|
||||||
name: 'SUPER_ADMIN',
|
|
||||||
color: 'red',
|
|
||||||
permissions: ['Full Access', 'User Mgmt', 'Role Mgmt', 'App Config', 'Logs & Errors']
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'DEVELOPER',
|
name: 'DEVELOPER',
|
||||||
color: 'brand-blue',
|
color: 'red',
|
||||||
permissions: ['View All Apps', 'Manage Assigned App', 'View Logs', 'Resolve Errors', 'Village Setup']
|
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'QA',
|
name: 'ADMIN',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
permissions: ['View All Apps', 'View Logs', 'Report Errors', 'Test App Features']
|
permissions: ['View All Apps', 'View Logs', 'Report Errors']
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -414,10 +409,8 @@ function UsersPage() {
|
|||||||
<Select
|
<Select
|
||||||
label="Role"
|
label="Role"
|
||||||
data={[
|
data={[
|
||||||
{ value: 'USER', label: 'User' },
|
|
||||||
{ value: 'ADMIN', label: 'Admin' },
|
{ value: 'ADMIN', label: 'Admin' },
|
||||||
{ value: 'DEVELOPER', label: 'Developer' },
|
{ value: 'DEVELOPER', label: 'Developer' },
|
||||||
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
|
|
||||||
]}
|
]}
|
||||||
value={createForm.role}
|
value={createForm.role}
|
||||||
onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
|
onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
|
||||||
@@ -461,10 +454,8 @@ function UsersPage() {
|
|||||||
<Select
|
<Select
|
||||||
label="Role"
|
label="Role"
|
||||||
data={[
|
data={[
|
||||||
{ value: 'USER', label: 'User' },
|
|
||||||
{ value: 'ADMIN', label: 'Admin' },
|
{ value: 'ADMIN', label: 'Admin' },
|
||||||
{ value: 'DEVELOPER', label: 'Developer' },
|
{ value: 'DEVELOPER', label: 'Developer' },
|
||||||
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
|
|
||||||
]}
|
]}
|
||||||
value={editForm.role}
|
value={editForm.role}
|
||||||
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
|
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function createTestApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create a test user with hashed password, returns the user record */
|
/** Create a test user with hashed password, returns the user record */
|
||||||
export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'USER' | 'ADMIN' | 'SUPER_ADMIN' = 'USER') {
|
export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'ADMIN' | 'DEVELOPER' = 'DEVELOPER') {
|
||||||
const hashed = await Bun.password.hash(password, { algorithm: 'bcrypt' })
|
const hashed = await Bun.password.hash(password, { algorithm: 'bcrypt' })
|
||||||
return prisma.user.upsert({
|
return prisma.user.upsert({
|
||||||
where: { email },
|
where: { email },
|
||||||
|
|||||||
Reference in New Issue
Block a user