feat: add Google OAuth login with USER role and pending approval flow
- Add GET /api/auth/google and GET /api/auth/callback/google routes with CSRF state protection and account linking via googleId - Add getPublicOrigin() for dynamic redirect_uri (supports reverse proxy via X-Forwarded-Proto) - Add USER role to schema (default for new Google sign-ins), make password optional, add googleId and image fields - Role-based redirect after login: USER → /profile, ADMIN/DEVELOPER → /dashboard - Profile page shows pending approval alert for USER role - Dashboard redirects USER role back to profile - Login page shows specific error messages per OAuth error code Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
-- AlterEnum: add USER back to Role
|
||||||
|
BEGIN;
|
||||||
|
CREATE TYPE "Role_new" AS ENUM ('USER', '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 'USER';
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- AlterTable: make password nullable, change default role
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "password" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "role" SET DEFAULT 'USER';
|
||||||
|
|
||||||
|
-- AlterTable: add googleId column
|
||||||
|
ALTER TABLE "user" ADD COLUMN "googleId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_googleId_key" ON "user"("googleId");
|
||||||
@@ -9,6 +9,7 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
|
USER
|
||||||
ADMIN
|
ADMIN
|
||||||
DEVELOPER
|
DEVELOPER
|
||||||
}
|
}
|
||||||
@@ -41,8 +42,9 @@ model User {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String?
|
||||||
role Role @default(ADMIN)
|
googleId String? @unique
|
||||||
|
role Role @default(USER)
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
125
src/app.ts
125
src/app.ts
@@ -8,6 +8,14 @@ import { env } from './lib/env'
|
|||||||
import { createSystemLog } from './lib/logger'
|
import { createSystemLog } from './lib/logger'
|
||||||
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
|
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
|
||||||
|
|
||||||
|
function getPublicOrigin(request: Request): string {
|
||||||
|
if (process.env.BUN_PUBLIC_BASE_URL) return process.env.BUN_PUBLIC_BASE_URL.replace(/\/$/, '')
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim()
|
||||||
|
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
|
||||||
|
return `${proto ?? url.protocol.replace(':', '')}://${host}`
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthResult {
|
interface AuthResult {
|
||||||
actingUserId: string
|
actingUserId: string
|
||||||
reporterUserId: string | null // null jika via API key (tidak ada user spesifik)
|
reporterUserId: string | null // null jika via API key (tidak ada user spesifik)
|
||||||
@@ -80,10 +88,117 @@ export function createApp() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ─── Auth API ──────────────────────────────────────
|
// ─── Auth API ──────────────────────────────────────
|
||||||
|
// ─── Google OAuth ──────────────────────────────────
|
||||||
|
.get('/api/auth/google', ({ request }) => {
|
||||||
|
const origin = getPublicOrigin(request)
|
||||||
|
const state = crypto.randomUUID()
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: env.GOOGLE_CLIENT_ID,
|
||||||
|
redirect_uri: `${origin}/api/auth/callback/google`,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
access_type: 'online',
|
||||||
|
prompt: 'select_account',
|
||||||
|
})
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.set('Location', `https://accounts.google.com/o/oauth2/v2/auth?${params}`)
|
||||||
|
headers.set('Set-Cookie', `oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`)
|
||||||
|
return new Response(null, { status: 302, headers })
|
||||||
|
}, {
|
||||||
|
detail: {
|
||||||
|
summary: 'Google OAuth Login',
|
||||||
|
description: 'Menginisiasi alur Google OAuth. Meredirect pengguna ke halaman login Google.',
|
||||||
|
tags: ['Auth'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/api/auth/callback/google', async ({ query, request }) => {
|
||||||
|
const { code, state, error } = query as Record<string, string>
|
||||||
|
const origin = getPublicOrigin(request)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return new Response(null, { status: 302, headers: { Location: '/login?error=google_denied' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const storedState = cookie.match(/oauth_state=([^;]+)/)?.[1]
|
||||||
|
if (!state || !storedState || state !== storedState) {
|
||||||
|
return new Response(null, { status: 302, headers: { Location: '/login?error=invalid_state' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return new Response(null, { status: 302, headers: { Location: '/login?error=token_failed' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { access_token } = await tokenRes.json() as { access_token: string }
|
||||||
|
|
||||||
|
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||||
|
headers: { Authorization: `Bearer ${access_token}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!userInfoRes.ok) {
|
||||||
|
return new Response(null, { status: 302, headers: { Location: '/login?error=userinfo_failed' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: googleId, email, name, picture } = await userInfoRes.json() as {
|
||||||
|
id: string; email: string; name: string; picture?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await prisma.user.findFirst({
|
||||||
|
where: { OR: [{ googleId }, { email }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: { name, email, googleId, image: picture, role: 'USER' },
|
||||||
|
})
|
||||||
|
} else if (!user.googleId) {
|
||||||
|
user = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { googleId, image: picture ?? user.image },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
|
||||||
|
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 with Google')
|
||||||
|
|
||||||
|
const redirectPath = user.role === 'USER' ? '/profile' : '/dashboard'
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.append('Location', redirectPath)
|
||||||
|
headers.append('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`)
|
||||||
|
headers.append('Set-Cookie', 'oauth_state=; Path=/; HttpOnly; Max-Age=0')
|
||||||
|
return new Response(null, { status: 302, headers })
|
||||||
|
}, {
|
||||||
|
detail: {
|
||||||
|
summary: 'Google OAuth Callback',
|
||||||
|
description: 'Menerima callback dari Google, membuat/menautkan akun, membuat sesi, lalu meredirect ke /dashboard (ADMIN/DEVELOPER) atau /profile (USER baru).',
|
||||||
|
tags: ['Auth'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
.post('/api/auth/login', async ({ body, set }) => {
|
.post('/api/auth/login', async ({ body, set }) => {
|
||||||
const { email, password } = body
|
const { email, password } = body
|
||||||
let user = await prisma.user.findUnique({ where: { email } })
|
let user = await prisma.user.findUnique({ where: { email } })
|
||||||
if (!user || !(await Bun.password.verify(password, user.password))) {
|
if (!user || !user.password || !(await Bun.password.verify(password, user.password))) {
|
||||||
set.status = 401
|
set.status = 401
|
||||||
return { error: 'Email atau password salah' }
|
return { error: 'Email atau password salah' }
|
||||||
}
|
}
|
||||||
@@ -96,7 +211,7 @@ export function createApp() {
|
|||||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
||||||
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
||||||
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
|
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
|
||||||
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
|
return { user: { id: user.id, name: user.name, email: user.email, role: user.role, image: user.image } }
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
email: t.String({ format: 'email', description: 'Email pengguna' }),
|
email: t.String({ format: 'email', description: 'Email pengguna' }),
|
||||||
@@ -135,7 +250,7 @@ export function createApp() {
|
|||||||
if (!token) { set.status = 401; return { user: null } }
|
if (!token) { set.status = 401; return { user: null } }
|
||||||
const session = await prisma.session.findUnique({
|
const session = await prisma.session.findUnique({
|
||||||
where: { token },
|
where: { token },
|
||||||
include: { user: { select: { id: true, name: true, email: true, role: true } } },
|
include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } },
|
||||||
})
|
})
|
||||||
if (!session || session.expiresAt < new Date()) {
|
if (!session || session.expiresAt < new Date()) {
|
||||||
if (session) await prisma.session.delete({ where: { id: session.id } })
|
if (session) await prisma.session.delete({ where: { id: session.id } })
|
||||||
@@ -454,7 +569,7 @@ export function createApp() {
|
|||||||
name: t.String({ minLength: 1, description: 'Nama lengkap operator' }),
|
name: t.String({ minLength: 1, description: 'Nama lengkap operator' }),
|
||||||
email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }),
|
email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }),
|
||||||
password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }),
|
password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }),
|
||||||
role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role: ADMIN atau DEVELOPER' }),
|
role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role untuk akun yang dibuat manual: ADMIN atau DEVELOPER' }),
|
||||||
}),
|
}),
|
||||||
detail: {
|
detail: {
|
||||||
summary: 'Create Operator',
|
summary: 'Create Operator',
|
||||||
@@ -494,7 +609,7 @@ export function createApp() {
|
|||||||
body: t.Object({
|
body: t.Object({
|
||||||
name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })),
|
name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })),
|
||||||
email: t.Optional(t.String({ format: 'email', description: 'Email baru' })),
|
email: t.Optional(t.String({ format: 'email', description: 'Email baru' })),
|
||||||
role: t.Optional(t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })),
|
role: t.Optional(t.Union([t.Literal('USER'), t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })),
|
||||||
active: t.Optional(t.Boolean({ description: 'Status aktif operator' })),
|
active: t.Optional(t.Boolean({ description: 'Status aktif operator' })),
|
||||||
}),
|
}),
|
||||||
detail: {
|
detail: {
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
||||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||||
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
AppShell,
|
AppShell,
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Burger,
|
Burger,
|
||||||
Button,
|
Button,
|
||||||
|
Center,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
LoadingOverlay,
|
||||||
Menu,
|
Menu,
|
||||||
NavLink,
|
NavLink,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
useMantineColorScheme
|
useMantineColorScheme
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
@@ -26,6 +31,7 @@ import {
|
|||||||
TbApps,
|
TbApps,
|
||||||
TbArrowLeft,
|
TbArrowLeft,
|
||||||
TbChevronRight,
|
TbChevronRight,
|
||||||
|
TbClock,
|
||||||
TbDashboard,
|
TbDashboard,
|
||||||
TbDeviceMobile,
|
TbDeviceMobile,
|
||||||
TbHistory,
|
TbHistory,
|
||||||
@@ -54,10 +60,17 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
const currentPath = matches[matches.length - 1]?.pathname
|
const currentPath = matches[matches.length - 1]?.pathname
|
||||||
|
|
||||||
// ─── Connect to auth system ──────────────────────────
|
// ─── Connect to auth system ──────────────────────────
|
||||||
const { data: sessionData } = useSession()
|
const { data: sessionData, isLoading: sessionLoading } = useSession()
|
||||||
const user = sessionData?.user
|
const user = sessionData?.user
|
||||||
const logout = useLogout()
|
const logout = useLogout()
|
||||||
|
|
||||||
|
// Redirect USER role to profile (pending approval)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!sessionLoading && user?.role === 'USER') {
|
||||||
|
navigate({ to: '/profile' })
|
||||||
|
}
|
||||||
|
}, [user?.role, sessionLoading, navigate])
|
||||||
|
|
||||||
// ─── Fetch registered apps from database ─────────────
|
// ─── Fetch registered apps from database ─────────────
|
||||||
const { data: appsData } = useQuery({
|
const { data: appsData } = useQuery({
|
||||||
queryKey: ['apps'],
|
queryKey: ['apps'],
|
||||||
@@ -99,6 +112,15 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
logout.mutate()
|
logout.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent dashboard flash for USER role while redirect is happening
|
||||||
|
if (sessionLoading || user?.role === 'USER') {
|
||||||
|
return (
|
||||||
|
<Center mih="100vh">
|
||||||
|
<LoadingOverlay visible />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 70 }}
|
header={{ height: 70 }}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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 = | 'ADMIN' | 'DEVELOPER'
|
export type Role = 'USER' | 'ADMIN' | 'DEVELOPER'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
role: Role
|
role: Role
|
||||||
|
image?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -41,7 +42,7 @@ export function useLogin() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(['auth', 'session'], data)
|
queryClient.setQueryData(['auth', 'session'], data)
|
||||||
navigate({ to: '/dashboard' })
|
navigate({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '/dashboard' })
|
throw redirect({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) return
|
if (e instanceof Error) return
|
||||||
@@ -59,7 +59,14 @@ function LoginPage() {
|
|||||||
|
|
||||||
{(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_denied: 'Login dengan Google dibatalkan.',
|
||||||
|
invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.',
|
||||||
|
token_failed: 'Gagal menukar token Google, silakan coba lagi.',
|
||||||
|
userinfo_failed: 'Gagal mengambil info akun Google, silakan coba lagi.',
|
||||||
|
}[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.'
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -89,6 +96,17 @@ function LoginPage() {
|
|||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Divider label="or" labelPosition="center" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
fullWidth
|
||||||
|
leftSection={<FcGoogle size={18} />}
|
||||||
|
onClick={() => { window.location.href = '/api/auth/google' }}
|
||||||
|
>
|
||||||
|
Continue with Google
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { TbLogout, TbUser } from 'react-icons/tb'
|
import { TbClock, TbLogout, TbUser } from 'react-icons/tb'
|
||||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/profile')({
|
export const Route = createFileRoute('/profile')({
|
||||||
@@ -30,6 +31,7 @@ export const Route = createFileRoute('/profile')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const roleBadgeColor: Record<string, string> = {
|
const roleBadgeColor: Record<string, string> = {
|
||||||
|
USER: 'gray',
|
||||||
ADMIN: 'violet',
|
ADMIN: 'violet',
|
||||||
DEVELOPER: 'red',
|
DEVELOPER: 'red',
|
||||||
}
|
}
|
||||||
@@ -55,9 +57,26 @@ function ProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{user?.role === 'USER' && (
|
||||||
|
<Alert
|
||||||
|
icon={<TbClock size={18} />}
|
||||||
|
title="Akun Menunggu Persetujuan"
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Akun kamu sedang menunggu persetujuan admin. Hubungi admin atau developer untuk mendapatkan akses ke fitur dashboard.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Paper withBorder p="xl" radius="md">
|
<Paper withBorder p="xl" radius="md">
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<Avatar color="blue" radius="xl" size={80}>
|
<Avatar
|
||||||
|
src={user?.image ?? undefined}
|
||||||
|
color="blue"
|
||||||
|
radius="xl"
|
||||||
|
size={80}
|
||||||
|
>
|
||||||
{user?.name?.charAt(0).toUpperCase()}
|
{user?.name?.charAt(0).toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
|||||||
@@ -50,10 +50,8 @@ export const Route = createFileRoute('/users')({
|
|||||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||||
|
|
||||||
const getRoleColor = (role: string) => {
|
const getRoleColor = (role: string) => {
|
||||||
const r = (role || '').toLowerCase()
|
if (role === 'DEVELOPER') return 'violet'
|
||||||
if (r.includes('super')) return 'red'
|
if (role === 'ADMIN') return 'brand-blue'
|
||||||
if (r.includes('admin')) return 'brand-blue'
|
|
||||||
if (r.includes('developer')) return 'violet'
|
|
||||||
return 'gray'
|
return 'gray'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +95,7 @@ function UsersPage() {
|
|||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'USER',
|
role: 'ADMIN',
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleCreateUser = async () => {
|
const handleCreateUser = async () => {
|
||||||
@@ -119,7 +117,7 @@ function UsersPage() {
|
|||||||
mutateOperators()
|
mutateOperators()
|
||||||
mutateStats()
|
mutateStats()
|
||||||
closeCreate()
|
closeCreate()
|
||||||
setCreateForm({ name: '', email: '', password: '', role: 'USER' })
|
setCreateForm({ name: '', email: '', password: '', role: 'ADMIN' })
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json()
|
const err = await res.json()
|
||||||
throw new Error(err.error || 'Failed to create user')
|
throw new Error(err.error || 'Failed to create user')
|
||||||
@@ -457,11 +455,12 @@ function UsersPage() {
|
|||||||
<Select
|
<Select
|
||||||
label="Role"
|
label="Role"
|
||||||
data={[
|
data={[
|
||||||
|
{ value: 'USER', label: 'User (Pending)' },
|
||||||
{ value: 'ADMIN', label: 'Admin' },
|
{ value: 'ADMIN', label: 'Admin' },
|
||||||
{ value: 'DEVELOPER', label: 'Developer' },
|
{ value: 'DEVELOPER', label: 'Developer' },
|
||||||
]}
|
]}
|
||||||
value={editForm.role}
|
value={editForm.role}
|
||||||
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
|
onChange={(val) => setEditForm({ ...editForm, role: val || 'ADMIN' })}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const env = {
|
|||||||
PORT: parseInt(optional('PORT', '3000'), 10),
|
PORT: parseInt(optional('PORT', '3000'), 10),
|
||||||
NODE_ENV: optional('NODE_ENV', 'development'),
|
NODE_ENV: optional('NODE_ENV', 'development'),
|
||||||
REACT_EDITOR: optional('REACT_EDITOR', 'code'),
|
REACT_EDITOR: optional('REACT_EDITOR', 'code'),
|
||||||
|
BASE_URL: optional('BUN_PUBLIC_BASE_URL', 'http://localhost:3000'),
|
||||||
DATABASE_URL: required('DATABASE_URL'),
|
DATABASE_URL: required('DATABASE_URL'),
|
||||||
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
|
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
|
||||||
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
|
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
|
||||||
|
|||||||
Reference in New Issue
Block a user