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:
Leonid Pershin
2026-07-24 05:40:34 +03:00
commit 8a3eebc48f
156 changed files with 9335 additions and 0 deletions
+136
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { createFileRoute, Navigate } from '@tanstack/react-router'
export const Route = createFileRoute('/admin/')({
component: () => <Navigate to="/admin/users" />,
})
+4
View File
@@ -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 })
+4
View File
@@ -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 })
+38
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
+117
View File
@@ -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>
)
}