upd: auth

Deskripsi:
-update login
- update struktur database

No Issues
This commit is contained in:
2026-04-15 11:17:04 +08:00
parent 840a89ea0a
commit c66ce4a39b
14 changed files with 273 additions and 173 deletions

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "App" ALTER COLUMN "version" DROP NOT NULL,
ALTER COLUMN "minVersion" DROP NOT NULL;

View File

@@ -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[]

View File

@@ -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}`)
} }
} }

View File

@@ -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!',

View File

@@ -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">

View File

@@ -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>

View File

@@ -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' })
}
}, },
}) })
} }

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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' })}

View File

@@ -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 },