From 3fe0909ed0b297f32112dd028353db503d1bd9c7 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 14 Oct 2026 19:58:06 +0100 Subject: [PATCH 1/2] Add permissions, seeding and permission middleware Define the permission list in internal/permissions and seed it on startup along with an admin role that holds every permission. Add HasPermission and RequirePermission for guarding routes, and include the user's permissions in the /me response. --- cmd/naplata/main.go | 11 ++++- internal/database/permissions.go | 48 +++++++++++++++++++++ internal/database/seed.go | 67 +++++++++++++++++++++++++++++ internal/permissions/permissions.go | 28 ++++++++++++ internal/server/auth.go | 29 ++++++++----- internal/server/middleware.go | 24 +++++++++++ 6 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 internal/database/permissions.go create mode 100644 internal/database/seed.go create mode 100644 internal/permissions/permissions.go diff --git a/cmd/naplata/main.go b/cmd/naplata/main.go index 3a6c584..7b67b7a 100644 --- a/cmd/naplata/main.go +++ b/cmd/naplata/main.go @@ -8,6 +8,7 @@ import ( "github.com/mtiluk/naplata/internal/config" "github.com/mtiluk/naplata/internal/database" + "github.com/mtiluk/naplata/internal/permissions" "github.com/mtiluk/naplata/internal/server" "github.com/mtiluk/naplata/migrations" "github.com/mtiluk/naplata/web" @@ -35,9 +36,17 @@ func main() { slog.Info("migrations applied") + store := database.NewStore(db) + + if err := store.SeedPermissions(context.Background(), permissions.All); err != nil { + log.Fatal(err) + } + + slog.Info("permissions seeded", "count", len(permissions.All)) + sessionManager := server.NewSessionManager(cfg, db) - h := server.NewHandler(database.NewStore(db), sessionManager) + h := server.NewHandler(store, sessionManager) mux := http.NewServeMux() diff --git a/internal/database/permissions.go b/internal/database/permissions.go new file mode 100644 index 0000000..a2c184a --- /dev/null +++ b/internal/database/permissions.go @@ -0,0 +1,48 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +func (s *Store) HasPermission(ctx context.Context, userID, permission string) (bool, error) { + var ok bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 AND p.name = $2 + )`, + userID, permission, + ).Scan(&ok) + if err != nil { + return false, fmt.Errorf("has permission: %w", err) + } + + return ok, nil +} + +func (s *Store) GetUserPermissions(ctx context.Context, userID string) ([]string, error) { + rows, _ := s.pool.Query(ctx, ` + SELECT DISTINCT p.name + FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 + ORDER BY p.name`, + userID, + ) + permissions, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + return nil, fmt.Errorf("get user permissions: %w", err) + } + if permissions == nil { + permissions = []string{} + } + + return permissions, nil +} diff --git a/internal/database/seed.go b/internal/database/seed.go new file mode 100644 index 0000000..467856b --- /dev/null +++ b/internal/database/seed.go @@ -0,0 +1,67 @@ +package database + +import ( + "context" + "fmt" + + "github.com/mtiluk/naplata/internal/permissions" +) + +func (s *Store) SeedPermissions(ctx context.Context, perms []permissions.Permission) error { + names := make([]string, len(perms)) + descriptions := make([]string, len(perms)) + for i, p := range perms { + names[i] = p.Name + descriptions[i] = p.Description + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin seed: %w", err) + } + defer tx.Rollback(ctx) + + // Only insert missing rows: INSERT ... ON CONFLICT draws an id even when it conflicts. + if _, err := tx.Exec(ctx, ` + INSERT INTO permissions (name, description) + SELECT t.name, t.description + FROM unnest($1::text[], $2::text[]) AS t(name, description) + WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.name = t.name) + ON CONFLICT (name) DO NOTHING`, + names, descriptions, + ); err != nil { + return fmt.Errorf("seed permissions: %w", err) + } + + if _, err := tx.Exec(ctx, ` + UPDATE permissions p + SET description = t.description, updated_at = now() + FROM unnest($1::text[], $2::text[]) AS t(name, description) + WHERE p.name = t.name AND p.description IS DISTINCT FROM t.description`, + names, descriptions, + ); err != nil { + return fmt.Errorf("update permission descriptions: %w", err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO roles (name) + SELECT 'admin' + WHERE NOT EXISTS (SELECT 1 FROM roles WHERE name = 'admin') + ON CONFLICT (name) DO NOTHING`, + ); err != nil { + return fmt.Errorf("seed admin role: %w", err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO role_permissions (role_id, permission_id) + SELECT r.id, p.id + FROM roles r + CROSS JOIN permissions p + WHERE r.name = 'admin' + ON CONFLICT DO NOTHING`, + ); err != nil { + return fmt.Errorf("grant admin permissions: %w", err) + } + + return tx.Commit(ctx) +} diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go new file mode 100644 index 0000000..8d190d1 --- /dev/null +++ b/internal/permissions/permissions.go @@ -0,0 +1,28 @@ +package permissions + +const ( + AccessPanel = "access.panel" + ClientsView = "clients.view" + ClientsManage = "clients.manage" + ProductsManage = "products.manage" + ServicesManage = "services.manage" + InvoicesManage = "invoices.manage" + PaymentsRefund = "payments.refund" + SettingsManage = "settings.manage" +) + +type Permission struct { + Name string + Description string +} + +var All = []Permission{ + {AccessPanel, "Access the panel"}, + {ClientsView, "View client accounts and their services"}, + {ClientsManage, "Edit, suspend and delete client accounts"}, + {ProductsManage, "Create and edit products and pricing"}, + {ServicesManage, "Provision, suspend and terminate services"}, + {InvoicesManage, "Create, edit and cancel invoices"}, + {PaymentsRefund, "Issue refunds"}, + {SettingsManage, "Change system settings"}, +} diff --git a/internal/server/auth.go b/internal/server/auth.go index c47e8ae..3dfbb42 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -18,11 +18,12 @@ type registerRequest struct { } type userResponse struct { - ID string `json:"id"` - Email string `json:"email"` - GivenName string `json:"given_name"` - FamilyName string `json:"family_name"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Email string `json:"email"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + CreatedAt time.Time `json:"created_at"` + Permissions []string `json:"permissions"` } func (h *Handler) RegisterEndpoint(w http.ResponseWriter, r *http.Request) { @@ -162,11 +163,19 @@ func (h *Handler) MeEndpoint(w http.ResponseWriter, r *http.Request) { return } + permissions, err := h.store.GetUserPermissions(r.Context(), user.ID) + if err != nil { + slog.Error("get user permissions", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + writeJSON(w, http.StatusOK, userResponse{ - ID: user.ID, - Email: user.Email, - GivenName: user.GivenName, - FamilyName: user.FamilyName, - CreatedAt: user.CreatedAt, + ID: user.ID, + Email: user.Email, + GivenName: user.GivenName, + FamilyName: user.FamilyName, + CreatedAt: user.CreatedAt, + Permissions: permissions, }) } diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 3e52e17..93e89b0 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -1,6 +1,7 @@ package server import ( + "log/slog" "net/http" ) @@ -13,3 +14,26 @@ func (h *Handler) RequireAuth(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func (h *Handler) RequirePermission(next http.Handler, permission string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := h.sessionManager.GetString(r.Context(), sessionUserIDKey) + if userID == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not logged in"}) + return + } + + hasPermission, err := h.store.HasPermission(r.Context(), userID, permission) + if err != nil { + slog.Error("check permission", "permission", permission, "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + if !hasPermission { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "permission denied"}) + return + } + + next.ServeHTTP(w, r) + }) +} From f0ed6ad96970088468fa61b5ba4a7b9fdeca7e56 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 14 Oct 2026 19:58:06 +0100 Subject: [PATCH 2/2] Add an admin area gated by permissions Mirror the permission names as TypeScript constants and type them on the user. Add /admin, which requires access.panel, with a sidebar and placeholder pages that each check their own permission. --- web/src/api/auth/me.ts | 3 + web/src/api/auth/permissions.ts | 12 ++ web/src/routeTree.gen.ts | 176 ++++++++++++++++++++++++++++++ web/src/routes/admin/clients.tsx | 16 +++ web/src/routes/admin/index.tsx | 9 ++ web/src/routes/admin/invoices.tsx | 16 +++ web/src/routes/admin/products.tsx | 16 +++ web/src/routes/admin/refunds.tsx | 16 +++ web/src/routes/admin/route.tsx | 106 ++++++++++++++++++ web/src/routes/admin/services.tsx | 16 +++ web/src/routes/admin/settings.tsx | 16 +++ 11 files changed, 402 insertions(+) create mode 100644 web/src/api/auth/permissions.ts create mode 100644 web/src/routes/admin/clients.tsx create mode 100644 web/src/routes/admin/index.tsx create mode 100644 web/src/routes/admin/invoices.tsx create mode 100644 web/src/routes/admin/products.tsx create mode 100644 web/src/routes/admin/refunds.tsx create mode 100644 web/src/routes/admin/route.tsx create mode 100644 web/src/routes/admin/services.tsx create mode 100644 web/src/routes/admin/settings.tsx diff --git a/web/src/api/auth/me.ts b/web/src/api/auth/me.ts index dad2ff8..7cbb235 100644 --- a/web/src/api/auth/me.ts +++ b/web/src/api/auth/me.ts @@ -1,11 +1,14 @@ import { queryOptions } from "@tanstack/react-query"; +import type { Permission } from "@/api/auth/permissions"; + export type User = { id: string; email: string; given_name: string; family_name: string; created_at: string; + permissions: Permission[]; }; export const meQuery = queryOptions({ diff --git a/web/src/api/auth/permissions.ts b/web/src/api/auth/permissions.ts new file mode 100644 index 0000000..a376ba8 --- /dev/null +++ b/web/src/api/auth/permissions.ts @@ -0,0 +1,12 @@ +export const Permission = { + AccessPanel: "access.panel", + ClientsView: "clients.view", + ClientsManage: "clients.manage", + ProductsManage: "products.manage", + ServicesManage: "services.manage", + InvoicesManage: "invoices.manage", + PaymentsRefund: "payments.refund", + SettingsManage: "settings.manage", +} as const; + +export type Permission = (typeof Permission)[keyof typeof Permission]; diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index aa9f64e..e8677dd 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -11,10 +11,18 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as GuestRouteImport } from './routes/_guest' +import { Route as AdminRouteRouteImport } from './routes/admin/route' import { Route as ClientRouteRouteImport } from './routes/client/route' import { Route as authLogoutRouteImport } from './routes/(auth)/logout' import { Route as GuestLoginRouteImport } from './routes/_guest/login' import { Route as GuestRegisterRouteImport } from './routes/_guest/register' +import { Route as AdminIndexRouteImport } from './routes/admin/index' +import { Route as AdminClientsRouteImport } from './routes/admin/clients' +import { Route as AdminInvoicesRouteImport } from './routes/admin/invoices' +import { Route as AdminProductsRouteImport } from './routes/admin/products' +import { Route as AdminRefundsRouteImport } from './routes/admin/refunds' +import { Route as AdminServicesRouteImport } from './routes/admin/services' +import { Route as AdminSettingsRouteImport } from './routes/admin/settings' import { Route as ClientIndexRouteImport } from './routes/client/index' import { Route as ClientAccountRouteImport } from './routes/client/account' import { Route as ClientAffiliatesRouteImport } from './routes/client/affiliates' @@ -41,6 +49,11 @@ const GuestRoute = GuestRouteImport.update({ id: '/_guest', getParentRoute: () => rootRouteImport, } as any) +const AdminRouteRoute = AdminRouteRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) const ClientRouteRoute = ClientRouteRouteImport.update({ id: '/client', path: '/client', @@ -61,6 +74,41 @@ const GuestRegisterRoute = GuestRegisterRouteImport.update({ path: '/register', getParentRoute: () => GuestRoute, } as any) +const AdminIndexRoute = AdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminClientsRoute = AdminClientsRouteImport.update({ + id: '/clients', + path: '/clients', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminInvoicesRoute = AdminInvoicesRouteImport.update({ + id: '/invoices', + path: '/invoices', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminProductsRoute = AdminProductsRouteImport.update({ + id: '/products', + path: '/products', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminRefundsRoute = AdminRefundsRouteImport.update({ + id: '/refunds', + path: '/refunds', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminServicesRoute = AdminServicesRouteImport.update({ + id: '/services', + path: '/services', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminSettingsRoute = AdminSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AdminRouteRoute, +} as any) const ClientIndexRoute = ClientIndexRouteImport.update({ id: '/', path: '/', @@ -144,10 +192,17 @@ const ClientServicesServiceIdRoute = ClientServicesServiceIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/admin': typeof AdminRouteRouteWithChildren '/client': typeof ClientRouteRouteWithChildren '/logout': typeof authLogoutRoute '/login': typeof GuestLoginRoute '/register': typeof GuestRegisterRoute + '/admin/clients': typeof AdminClientsRoute + '/admin/invoices': typeof AdminInvoicesRoute + '/admin/products': typeof AdminProductsRoute + '/admin/refunds': typeof AdminRefundsRoute + '/admin/services': typeof AdminServicesRoute + '/admin/settings': typeof AdminSettingsRoute '/client/account': typeof ClientAccountRoute '/client/affiliates': typeof ClientAffiliatesRoute '/client/announcements': typeof ClientAnnouncementsRoute @@ -161,6 +216,7 @@ export interface FileRoutesByFullPath { '/client/store': typeof ClientStoreRoute '/client/tickets': typeof ClientTicketsRoute '/client/transactions': typeof ClientTransactionsRoute + '/admin/': typeof AdminIndexRoute '/client/': typeof ClientIndexRoute '/client/services/$serviceId': typeof ClientServicesServiceIdRoute '/client/services/': typeof ClientServicesIndexRoute @@ -170,6 +226,12 @@ export interface FileRoutesByTo { '/logout': typeof authLogoutRoute '/login': typeof GuestLoginRoute '/register': typeof GuestRegisterRoute + '/admin/clients': typeof AdminClientsRoute + '/admin/invoices': typeof AdminInvoicesRoute + '/admin/products': typeof AdminProductsRoute + '/admin/refunds': typeof AdminRefundsRoute + '/admin/services': typeof AdminServicesRoute + '/admin/settings': typeof AdminSettingsRoute '/client/account': typeof ClientAccountRoute '/client/affiliates': typeof ClientAffiliatesRoute '/client/announcements': typeof ClientAnnouncementsRoute @@ -183,6 +245,7 @@ export interface FileRoutesByTo { '/client/store': typeof ClientStoreRoute '/client/tickets': typeof ClientTicketsRoute '/client/transactions': typeof ClientTransactionsRoute + '/admin': typeof AdminIndexRoute '/client': typeof ClientIndexRoute '/client/services/$serviceId': typeof ClientServicesServiceIdRoute '/client/services': typeof ClientServicesIndexRoute @@ -190,11 +253,18 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/admin': typeof AdminRouteRouteWithChildren '/client': typeof ClientRouteRouteWithChildren '/_guest': typeof GuestRouteWithChildren '/(auth)/logout': typeof authLogoutRoute '/_guest/login': typeof GuestLoginRoute '/_guest/register': typeof GuestRegisterRoute + '/admin/clients': typeof AdminClientsRoute + '/admin/invoices': typeof AdminInvoicesRoute + '/admin/products': typeof AdminProductsRoute + '/admin/refunds': typeof AdminRefundsRoute + '/admin/services': typeof AdminServicesRoute + '/admin/settings': typeof AdminSettingsRoute '/client/account': typeof ClientAccountRoute '/client/affiliates': typeof ClientAffiliatesRoute '/client/announcements': typeof ClientAnnouncementsRoute @@ -208,6 +278,7 @@ export interface FileRoutesById { '/client/store': typeof ClientStoreRoute '/client/tickets': typeof ClientTicketsRoute '/client/transactions': typeof ClientTransactionsRoute + '/admin/': typeof AdminIndexRoute '/client/': typeof ClientIndexRoute '/client/services/$serviceId': typeof ClientServicesServiceIdRoute '/client/services/': typeof ClientServicesIndexRoute @@ -216,10 +287,17 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/admin' | '/client' | '/logout' | '/login' | '/register' + | '/admin/clients' + | '/admin/invoices' + | '/admin/products' + | '/admin/refunds' + | '/admin/services' + | '/admin/settings' | '/client/account' | '/client/affiliates' | '/client/announcements' @@ -233,6 +311,7 @@ export interface FileRouteTypes { | '/client/store' | '/client/tickets' | '/client/transactions' + | '/admin/' | '/client/' | '/client/services/$serviceId' | '/client/services/' @@ -242,6 +321,12 @@ export interface FileRouteTypes { | '/logout' | '/login' | '/register' + | '/admin/clients' + | '/admin/invoices' + | '/admin/products' + | '/admin/refunds' + | '/admin/services' + | '/admin/settings' | '/client/account' | '/client/affiliates' | '/client/announcements' @@ -255,17 +340,25 @@ export interface FileRouteTypes { | '/client/store' | '/client/tickets' | '/client/transactions' + | '/admin' | '/client' | '/client/services/$serviceId' | '/client/services' id: | '__root__' | '/' + | '/admin' | '/client' | '/_guest' | '/(auth)/logout' | '/_guest/login' | '/_guest/register' + | '/admin/clients' + | '/admin/invoices' + | '/admin/products' + | '/admin/refunds' + | '/admin/services' + | '/admin/settings' | '/client/account' | '/client/affiliates' | '/client/announcements' @@ -279,6 +372,7 @@ export interface FileRouteTypes { | '/client/store' | '/client/tickets' | '/client/transactions' + | '/admin/' | '/client/' | '/client/services/$serviceId' | '/client/services/' @@ -286,6 +380,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AdminRouteRoute: typeof AdminRouteRouteWithChildren ClientRouteRoute: typeof ClientRouteRouteWithChildren GuestRoute: typeof GuestRouteWithChildren authLogoutRoute: typeof authLogoutRoute @@ -307,6 +402,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GuestRouteImport parentRoute: typeof rootRouteImport } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteRouteImport + parentRoute: typeof rootRouteImport + } '/client': { id: '/client' path: '/client' @@ -335,6 +437,55 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GuestRegisterRouteImport parentRoute: typeof GuestRoute } + '/admin/': { + id: '/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof AdminIndexRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/clients': { + id: '/admin/clients' + path: '/clients' + fullPath: '/admin/clients' + preLoaderRoute: typeof AdminClientsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/invoices': { + id: '/admin/invoices' + path: '/invoices' + fullPath: '/admin/invoices' + preLoaderRoute: typeof AdminInvoicesRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/products': { + id: '/admin/products' + path: '/products' + fullPath: '/admin/products' + preLoaderRoute: typeof AdminProductsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/refunds': { + id: '/admin/refunds' + path: '/refunds' + fullPath: '/admin/refunds' + preLoaderRoute: typeof AdminRefundsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/services': { + id: '/admin/services' + path: '/services' + fullPath: '/admin/services' + preLoaderRoute: typeof AdminServicesRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/settings': { + id: '/admin/settings' + path: '/settings' + fullPath: '/admin/settings' + preLoaderRoute: typeof AdminSettingsRouteImport + parentRoute: typeof AdminRouteRoute + } '/client/': { id: '/client/' path: '/' @@ -450,6 +601,30 @@ declare module '@tanstack/react-router' { } } +interface AdminRouteRouteChildren { + AdminClientsRoute: typeof AdminClientsRoute + AdminInvoicesRoute: typeof AdminInvoicesRoute + AdminProductsRoute: typeof AdminProductsRoute + AdminRefundsRoute: typeof AdminRefundsRoute + AdminServicesRoute: typeof AdminServicesRoute + AdminSettingsRoute: typeof AdminSettingsRoute + AdminIndexRoute: typeof AdminIndexRoute +} + +const AdminRouteRouteChildren: AdminRouteRouteChildren = { + AdminClientsRoute: AdminClientsRoute, + AdminInvoicesRoute: AdminInvoicesRoute, + AdminProductsRoute: AdminProductsRoute, + AdminRefundsRoute: AdminRefundsRoute, + AdminServicesRoute: AdminServicesRoute, + AdminSettingsRoute: AdminSettingsRoute, + AdminIndexRoute: AdminIndexRoute, +} + +const AdminRouteRouteWithChildren = AdminRouteRoute._addFileChildren( + AdminRouteRouteChildren, +) + interface ClientRouteRouteChildren { ClientAccountRoute: typeof ClientAccountRoute ClientAffiliatesRoute: typeof ClientAffiliatesRoute @@ -506,6 +681,7 @@ const GuestRouteWithChildren = GuestRoute._addFileChildren(GuestRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AdminRouteRoute: AdminRouteRouteWithChildren, ClientRouteRoute: ClientRouteRouteWithChildren, GuestRoute: GuestRouteWithChildren, authLogoutRoute: authLogoutRoute, diff --git a/web/src/routes/admin/clients.tsx b/web/src/routes/admin/clients.tsx new file mode 100644 index 0000000..57c883e --- /dev/null +++ b/web/src/routes/admin/clients.tsx @@ -0,0 +1,16 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { Permission } from '@/api/auth/permissions' + +export const Route = createFileRoute('/admin/clients')({ + beforeLoad: ({ context }) => { + if (!context.user.permissions.includes(Permission.ClientsView)) { + throw redirect({ to: '/admin' }) + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + return

Clients

+} diff --git a/web/src/routes/admin/index.tsx b/web/src/routes/admin/index.tsx new file mode 100644 index 0000000..8fb0355 --- /dev/null +++ b/web/src/routes/admin/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/admin/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return

Overview

+} diff --git a/web/src/routes/admin/invoices.tsx b/web/src/routes/admin/invoices.tsx new file mode 100644 index 0000000..5ce60fe --- /dev/null +++ b/web/src/routes/admin/invoices.tsx @@ -0,0 +1,16 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { Permission } from '@/api/auth/permissions' + +export const Route = createFileRoute('/admin/invoices')({ + beforeLoad: ({ context }) => { + if (!context.user.permissions.includes(Permission.InvoicesManage)) { + throw redirect({ to: '/admin' }) + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + return

Invoices

+} diff --git a/web/src/routes/admin/products.tsx b/web/src/routes/admin/products.tsx new file mode 100644 index 0000000..23a7582 --- /dev/null +++ b/web/src/routes/admin/products.tsx @@ -0,0 +1,16 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { Permission } from '@/api/auth/permissions' + +export const Route = createFileRoute('/admin/products')({ + beforeLoad: ({ context }) => { + if (!context.user.permissions.includes(Permission.ProductsManage)) { + throw redirect({ to: '/admin' }) + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + return

Products

+} diff --git a/web/src/routes/admin/refunds.tsx b/web/src/routes/admin/refunds.tsx new file mode 100644 index 0000000..45d1fb1 --- /dev/null +++ b/web/src/routes/admin/refunds.tsx @@ -0,0 +1,16 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { Permission } from '@/api/auth/permissions' + +export const Route = createFileRoute('/admin/refunds')({ + beforeLoad: ({ context }) => { + if (!context.user.permissions.includes(Permission.PaymentsRefund)) { + throw redirect({ to: '/admin' }) + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + return

Refunds

+} diff --git a/web/src/routes/admin/route.tsx b/web/src/routes/admin/route.tsx new file mode 100644 index 0000000..2c7a889 --- /dev/null +++ b/web/src/routes/admin/route.tsx @@ -0,0 +1,106 @@ +import { Link, Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { ArrowUUpLeft } from "@phosphor-icons/react/ArrowUUpLeft"; +import { GearSix } from "@phosphor-icons/react/GearSix"; +import { HardDrives } from "@phosphor-icons/react/HardDrives"; +import { Package } from "@phosphor-icons/react/Package"; +import { Receipt } from "@phosphor-icons/react/Receipt"; +import { SignOut } from "@phosphor-icons/react/SignOut"; +import { SquaresFour } from "@phosphor-icons/react/SquaresFour"; +import { UsersThree } from "@phosphor-icons/react/UsersThree"; + +import { meQuery } from "@/api/auth/me"; +import { Permission } from "@/api/auth/permissions"; + +export const Route = createFileRoute("/admin")({ + beforeLoad: async ({ context, location }) => { + const user = await context.queryClient.query(meQuery); + if (!user) { + throw redirect({ to: "/login", search: { redirect: location.href } }); + } + if (!user.permissions.includes(Permission.AccessPanel)) { + throw redirect({ to: "/client" }); + } + return { user }; + }, + component: AdminLayout, +}); + +const NAV_ITEMS = [ + { label: "Overview", to: "/admin", icon: SquaresFour }, + { label: "Clients", to: "/admin/clients", icon: UsersThree, permission: Permission.ClientsView }, + { label: "Products", to: "/admin/products", icon: Package, permission: Permission.ProductsManage }, + { label: "Services", to: "/admin/services", icon: HardDrives, permission: Permission.ServicesManage }, + { label: "Invoices", to: "/admin/invoices", icon: Receipt, permission: Permission.InvoicesManage }, + { label: "Refunds", to: "/admin/refunds", icon: ArrowUUpLeft, permission: Permission.PaymentsRefund }, + { label: "Settings", to: "/admin/settings", icon: GearSix, permission: Permission.SettingsManage }, +] as const; + +function AdminLayout() { + const { user } = Route.useRouteContext(); + const name = [user.given_name, user.family_name].filter(Boolean).join(" "); + const initials = (user.given_name.charAt(0) + user.family_name.charAt(0)).toUpperCase(); + const items = NAV_ITEMS.filter((item) => !("permission" in item) || user.permissions.includes(item.permission)); + + return ( +
+
+
+ + Naplata + + Admin +
+ +
+ + Client area + +
+

{name}

+

{user.email}

+
+ + {initials} + + + + +
+
+ +
+ + +
+ +
+
+
+ ); +} diff --git a/web/src/routes/admin/services.tsx b/web/src/routes/admin/services.tsx new file mode 100644 index 0000000..9a00828 --- /dev/null +++ b/web/src/routes/admin/services.tsx @@ -0,0 +1,16 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { Permission } from '@/api/auth/permissions' + +export const Route = createFileRoute('/admin/services')({ + beforeLoad: ({ context }) => { + if (!context.user.permissions.includes(Permission.ServicesManage)) { + throw redirect({ to: '/admin' }) + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + return

Services

+} diff --git a/web/src/routes/admin/settings.tsx b/web/src/routes/admin/settings.tsx new file mode 100644 index 0000000..5ae8b78 --- /dev/null +++ b/web/src/routes/admin/settings.tsx @@ -0,0 +1,16 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { Permission } from '@/api/auth/permissions' + +export const Route = createFileRoute('/admin/settings')({ + beforeLoad: ({ context }) => { + if (!context.user.permissions.includes(Permission.SettingsManage)) { + throw redirect({ to: '/admin' }) + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + return

Settings

+}