Initial commit: base slice (auth, roles, users, admin) scaffold
Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL + Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4 with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's conventions, scoped down to the current base feature set.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Menu, Radio, X } from 'lucide-react'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import { bootstrapSession, logout, clearSession } from '@/features/auth/api'
|
||||
import { useTheme } from '@/theme/ThemeProvider'
|
||||
import { setLanguage } from '@/shared/lib/i18n'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Route = createRootRoute({ component: RootLayout })
|
||||
|
||||
function RootLayout() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { user } = useAuthStore()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrapSession()
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
} finally {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-3">
|
||||
<Link to="/" className="crt-glow flex items-center gap-2 text-lg font-bold tracking-widest">
|
||||
<Radio className="h-5 w-5" />
|
||||
{t('appName')}
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-4 text-sm md:flex">
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/dashboard" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
<Link to="/settings" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" className="hover:text-primary" activeProps={{ className: 'text-primary' }}>
|
||||
{t('nav.admin')}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="rounded-sm border border-border bg-transparent px-2 py-1 text-xs uppercase"
|
||||
value={i18n.language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="ru">{t('lang.ru')}</option>
|
||||
<option value="en">{t('lang.en')}</option>
|
||||
</select>
|
||||
<select
|
||||
className="hidden rounded-sm border border-border bg-transparent px-2 py-1 text-xs uppercase sm:block"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as typeof theme)}
|
||||
>
|
||||
<option value="light">{t('theme.light')}</option>
|
||||
<option value="dark">{t('theme.dark')}</option>
|
||||
<option value="system">{t('theme.system')}</option>
|
||||
</select>
|
||||
|
||||
{user ? (
|
||||
<button
|
||||
className="hidden rounded-sm border border-border px-3 py-1 text-xs uppercase tracking-wide hover:bg-muted md:block"
|
||||
onClick={() => void handleLogout()}
|
||||
>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden rounded-sm border border-border px-3 py-1 text-xs uppercase tracking-wide hover:bg-muted md:block"
|
||||
>
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="rounded-sm border border-border p-1.5 md:hidden"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
{menuOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menuOpen && (
|
||||
<nav className="flex flex-col gap-1 border-t border-border px-4 py-3 text-sm md:hidden">
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/dashboard" onClick={() => setMenuOpen(false)}>
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
<Link to="/settings" onClick={() => setMenuOpen(false)}>
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" onClick={() => setMenuOpen(false)}>
|
||||
{t('nav.admin')}
|
||||
</Link>
|
||||
)}
|
||||
<button className="py-1 text-left" onClick={() => void handleLogout()}>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" onClick={() => setMenuOpen(false)}>
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className={cn('mx-auto w-full max-w-5xl flex-1 px-4 py-8')}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createFileRoute, Link, Outlet } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRequireAdmin } from '@/features/auth/guards'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export const Route = createFileRoute('/admin')({ component: AdminLayout })
|
||||
|
||||
function AdminLayout() {
|
||||
const { t } = useTranslation()
|
||||
const { isReady } = useRequireAdmin()
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.admin')}</h1>
|
||||
<nav className="flex gap-4 border-b border-border text-sm">
|
||||
<Link
|
||||
to="/admin/roles"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.roles.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/users"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.users.title')}
|
||||
</Link>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Navigate } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/admin/')({
|
||||
component: () => <Navigate to="/admin/users" />,
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { RolesPanel } from '@/features/admin/roles/RolesPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/roles')({ component: RolesPanel })
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { UsersPanel } from '@/features/admin/users/UsersPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/users')({ component: UsersPanel })
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tv } from 'lucide-react'
|
||||
import { useRequireAuth } from '@/features/auth/guards'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({ component: DashboardPage })
|
||||
|
||||
function DashboardPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user, isReady } = useRequireAuth()
|
||||
|
||||
if (!isReady || !user) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('dashboard.welcome', { userName: user.userName })}</h1>
|
||||
<Badge>
|
||||
{t('dashboard.role')}: {user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tv className="h-5 w-5" />
|
||||
{t('nav.dashboard')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{t('dashboard.placeholder')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tv } from 'lucide-react'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
export const Route = createFileRoute('/')({ component: HomePage })
|
||||
|
||||
function HomePage() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuthStore()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 py-16 text-center">
|
||||
<div className="crt-panel flex h-40 w-full max-w-md items-center justify-center rounded-md">
|
||||
<Tv className="crt-glow h-16 w-16" strokeWidth={1} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="crt-glow text-4xl font-bold tracking-[0.2em]">{t('home.title')}</h1>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-muted-foreground">{t('home.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<p className="max-w-md text-muted-foreground">{t('home.tagline')}</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{user ? (
|
||||
<Button asChild size="lg">
|
||||
<Link to="/dashboard">{t('home.cta')}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild size="lg">
|
||||
<Link to="/login">{t('home.cta')}</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link to="/register">{t('home.ctaRegister')}</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoginForm } from '@/features/auth/LoginForm'
|
||||
import { useRequireGuest } from '@/features/auth/guards'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
|
||||
export const Route = createFileRoute('/login')({ component: LoginPage })
|
||||
|
||||
function LoginPage() {
|
||||
useRequireGuest()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('auth.loginTitle')}</CardTitle>
|
||||
<CardDescription>{t('auth.loginSubtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm onSuccess={() => void navigate({ to: '/dashboard' })} />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-primary hover:underline">
|
||||
{t('nav.register')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RegisterForm } from '@/features/auth/RegisterForm'
|
||||
import { useRequireGuest } from '@/features/auth/guards'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
|
||||
export const Route = createFileRoute('/register')({ component: RegisterPage })
|
||||
|
||||
function RegisterPage() {
|
||||
useRequireGuest()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('auth.registerTitle')}</CardTitle>
|
||||
<CardDescription>{t('auth.registerSubtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RegisterForm onSuccess={() => void navigate({ to: '/dashboard' })} />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{t('auth.haveAccount')}{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { changePassword, changeUserName, clearSession, deleteAccount } from '@/features/auth/api'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import { useRequireAuth } from '@/features/auth/guards'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
|
||||
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
||||
|
||||
const userNameSchema = z.object({ newUserName: z.string().min(3).max(64) })
|
||||
const passwordSchema = z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8) })
|
||||
|
||||
function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { isReady } = useRequireAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const userNameForm = useForm<z.infer<typeof userNameSchema>>({ resolver: zodResolver(userNameSchema) })
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({ resolver: zodResolver(passwordSchema) })
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
const onSaveUserName = async (values: z.infer<typeof userNameSchema>) => {
|
||||
try {
|
||||
await changeUserName(values.newUserName)
|
||||
useAuthStore.getState().setUser({ ...useAuthStore.getState().user!, userName: values.newUserName })
|
||||
toast.success(t('settings.saved'))
|
||||
userNameForm.reset()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof HttpError && error.status === 409 ? t('auth.userNameTaken') : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const onSavePassword = async (values: z.infer<typeof passwordSchema>) => {
|
||||
try {
|
||||
await changePassword(values.currentPassword, values.newPassword)
|
||||
toast.success(t('settings.saved'))
|
||||
passwordForm.reset()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteAccount = async () => {
|
||||
if (!window.confirm(t('settings.deleteAccountConfirm'))) return
|
||||
try {
|
||||
await deleteAccount()
|
||||
clearSession()
|
||||
void navigate({ to: '/' })
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('settings.title')}</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.changeUserName')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={userNameForm.handleSubmit(onSaveUserName)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newUserName">{t('settings.newUserName')}</Label>
|
||||
<Input id="newUserName" {...userNameForm.register('newUserName')} />
|
||||
</div>
|
||||
<Button type="submit" className="self-start" disabled={userNameForm.formState.isSubmitting}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.changePassword')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={passwordForm.handleSubmit(onSavePassword)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="currentPassword">{t('settings.currentPassword')}</Label>
|
||||
<Input id="currentPassword" type="password" {...passwordForm.register('currentPassword')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newPassword">{t('settings.newPassword')}</Label>
|
||||
<Input id="newPassword" type="password" {...passwordForm.register('newPassword')} />
|
||||
</div>
|
||||
<Button type="submit" className="self-start" disabled={passwordForm.formState.isSubmitting}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-red-700/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-500">{t('settings.dangerZone')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="destructive" onClick={() => void onDeleteAccount()}>
|
||||
{t('settings.deleteAccount')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user