From b46807124d5a9520239ac493d3b1c23fbcb3b90c Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 19 Apr 2026 16:39:00 +0100 Subject: [PATCH 01/11] feat(front|api): remove scaler and use our own documentation to display docs from api --- api/internal/docs/handler.go | 31 +- front/src/pages/Docs/ApiDocs.tsx | 550 +++++++++++++++++++++++++++++++ front/src/pages/Docs/index.tsx | 3 + 3 files changed, 554 insertions(+), 30 deletions(-) create mode 100644 front/src/pages/Docs/ApiDocs.tsx diff --git a/api/internal/docs/handler.go b/api/internal/docs/handler.go index 1705c55..0d47b0f 100644 --- a/api/internal/docs/handler.go +++ b/api/internal/docs/handler.go @@ -2,7 +2,6 @@ package docs import ( "encoding/json" - "fmt" "net/http" ) @@ -16,35 +15,7 @@ func NewHandler(isProd bool) *Handler { func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // TODO: In production we should disable or password-protect these - mux.HandleFunc("GET /docs", h.scalar) - mux.HandleFunc("GET /docs/openapi.json", h.spec) -} - -func (h *Handler) scalar(w http.ResponseWriter, r *http.Request) { - spec, err := json.Marshal(openAPISpec()) - if err != nil { - http.Error(w, "failed to generate spec", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, ` - - - Operafix API - - - - - - - -`, string(spec)) + mux.HandleFunc("GET /docs", h.spec) } // spec serves the raw OpenAPI JSON for external tools (Postman, Insomnia, etc.) diff --git a/front/src/pages/Docs/ApiDocs.tsx b/front/src/pages/Docs/ApiDocs.tsx new file mode 100644 index 0000000..a1421f8 --- /dev/null +++ b/front/src/pages/Docs/ApiDocs.tsx @@ -0,0 +1,550 @@ +import { useState, useEffect, type FC } from 'react' +import { + Key, + Link as LinkIcon, + Layers, + ShieldAlert, + ChevronDown, + ChevronRight, + Copy, + Check, + Loader2, + AlertCircle, + Lock, + Unlock, +} from 'lucide-react' +import { cn } from '../../lib/utils' +import { Card, CardContent } from '../../components/Card' + +interface OpenAPISpec { + info: { title: string; description: string; version: string } + servers: Array<{ url: string; description: string }> + tags: Array<{ name: string; description: string }> + paths: Record> + components?: { + securitySchemes?: Record + schemas?: Record + } +} + +interface Operation { + tags: string[] + summary: string + description: string + operationId: string + security?: Array> + parameters?: Parameter[] + requestBody?: RequestBody + responses: Record +} + +interface Parameter { + in: string + name: string + required: boolean + description: string + schema: Schema +} + +interface RequestBody { + required: boolean + content: Record +} + +interface Response { + description: string + content?: Record +} + +interface Schema { + type?: string + format?: string + example?: unknown + $ref?: string + properties?: Record + required?: string[] + enum?: string[] +} + +interface SecurityScheme { + type: string + scheme?: string + bearerFormat?: string + description: string +} + +const METHOD_STYLES: Record = { + get: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', + post: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + put: 'bg-amber-500/10 text-amber-400 border-amber-500/20', + patch: 'bg-orange-500/10 text-orange-400 border-orange-500/20', + delete: 'bg-red-500/10 text-red-400 border-red-500/20', +} + +const STATUS_STYLES: Record = { + '2': 'text-emerald-400', + '4': 'text-amber-400', + '5': 'text-red-400', +} + +function MethodBadge({ method }: { method: string }) { + return ( + + {method.toUpperCase()} + + ) +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const copy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + return ( + + ) +} + +function CodeBlock({ children, language = 'json' }: { children: string; language?: string }) { + return ( +
+
+ + {language} + + +
+
+        {children}
+      
+
+ ) +} + +function EndpointRow({ + method, + path, + operation, + schemas, +}: { + method: string + path: string + operation: Operation + schemas: Record +}) { + const [open, setOpen] = useState(false) + const isSecured = (operation.security?.length ?? 0) > 0 + + const resolveExample = (schema?: Schema): string => { + if (!schema) return '{}' + if (schema.$ref) { + const name = schema.$ref.split('/').pop()! + const resolved = schemas[name] + if (resolved?.properties) { + const ex: Record = {} + for (const [k, v] of Object.entries(resolved.properties)) { + ex[k] = v.example ?? (v.type === 'string' ? '' : v.type === 'integer' ? 0 : null) + } + return JSON.stringify(ex, null, 2) + } + } + return JSON.stringify(schema.example ?? {}, null, 2) + } + + const requestExample = operation.requestBody + ? (() => { + const content = Object.values(operation.requestBody.content)[0] + return content?.example + ? JSON.stringify(content.example, null, 2) + : resolveExample(content?.schema) + })() + : null + + return ( +
+ {/* Row header — always visible */} + + + {/* Expanded detail */} + {open && ( +
+ + {/* Description + auth badge */} +
+

+ {operation.description} +

+ {isSecured ? ( + + Bearer JWT + + ) : ( + + Public + + )} +
+ +
+ + {/* Left — Parameters + Request body */} +
+ + {/* Parameters */} + {(operation.parameters?.length ?? 0) > 0 && ( +
+

+ Parameters +

+
+ {operation.parameters!.map(param => ( +
+
+
+ + {param.name} + + + {param.in} + + {param.required && ( + + required + + )} +
+

{param.description}

+
+ + {param.schema?.type} + +
+ ))} +
+
+ )} + + {/* Request body */} + {requestExample && ( +
+

+ Request body +

+ {requestExample} +
+ )} + + {/* curl example */} +
+

+ Example +

+ + {[ + `curl -X ${method.toUpperCase()} http://localhost:8081${path}`, + isSecured ? ` -H "Authorization: Bearer "` : null, + requestExample ? ` -H "Content-Type: application/json"` : null, + requestExample ? ` -d '${requestExample.replace(/\n/g, ' ')}'` : null, + ] + .filter(Boolean) + .join(' \\\n')} + +
+
+ + {/* Right — Responses */} +
+

+ Responses +

+
+ {Object.entries(operation.responses).map(([status, response]) => ( +
+
+ + {status} + + {response.description} +
+ {response.content && ( +
+ {Object.entries(response.content).map(([mime, content]) => ( +
+ {content.example ? ( +
+                                {JSON.stringify(content.example, null, 2)}
+                              
+ ) : content.schema?.$ref ? ( + + → {content.schema.$ref.split('/').pop()} + + ) : null} +
+ ))} +
+ )} +
+ ))} +
+
+
+
+ )} +
+ ) +} + +const TagSection = ({ + tag, + endpoints, + schemas, +}: { + tag: { name: string; description: string } + endpoints: Array<{ method: string; path: string; operation: Operation }> + schemas: Record +}) => { + const [collapsed, setCollapsed] = useState(false) + + return ( +
+ + + {!collapsed && ( +
+ {endpoints.map(({ method, path, operation }) => ( + + ))} +
+ )} +
+ ) +} + +export const ApiDocs: FC = () => { + const [spec, setSpec] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + fetch('/api/docs') + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + }) + .then(data => { + setSpec(data) + setLoading(false) + }) + .catch(err => { + setError(err.message) + setLoading(false) + }) + }, []) + + if (loading) { + return ( +
+ + Loading spec... +
+ ) + } + + if (error || !spec) { + return ( +
+ + + Failed to load API spec — is the API running?{' '} + {error} + +
+ ) + } + + const schemas = spec.components?.schemas ?? {} + const securitySchemes = spec.components?.securitySchemes ?? {} + + // Group endpoints by tag + const endpointsByTag: Record> = {} + + for (const [path, methods] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(methods)) { + const tag = operation.tags?.[0] ?? 'other' + if (!endpointsByTag[tag]) endpointsByTag[tag] = [] + endpointsByTag[tag].push({ method, path, operation }) + } + } + + return ( +
+ + {/* Header */} +
+
+
+

+ {spec.info.title} +

+

+ {spec.info.description} +

+
+ + v{spec.info.version} + +
+ + {/* Servers */} +
+ {spec.servers.map(server => ( +
+ +
+

+ {server.description} +

+ {server.url} +
+
+ ))} +
+
+ + {/* Auth schemes */} + {Object.keys(securitySchemes).length > 0 && ( +
+

+ Authentication +

+
+ {Object.entries(securitySchemes).map(([name, scheme]) => ( + + +
+ + {name} + + {scheme.scheme ?? scheme.type} + + {scheme.bearerFormat && ( + + {scheme.bearerFormat} + + )} +
+

+ {scheme.description} +

+
+
+ ))} +
+
+ )} + + {/* Endpoints grouped by tag */} +
+

+ Endpoints +

+ +
+ {spec.tags.map(tag => { + const endpoints = endpointsByTag[tag.name] ?? [] + if (endpoints.length === 0) return null + return ( + + ) + })} + {/* Any endpoints not covered by a declared tag */} + {endpointsByTag['other'] && ( + + )} +
+
+
+ ) +} diff --git a/front/src/pages/Docs/index.tsx b/front/src/pages/Docs/index.tsx index 3beabea..ba88e5b 100644 --- a/front/src/pages/Docs/index.tsx +++ b/front/src/pages/Docs/index.tsx @@ -5,6 +5,7 @@ import { cn } from '../../lib/utils.ts' import { ThemingDocs } from './ThemingDocs.tsx' import { WorkflowDocs } from './WorkflowDocs.tsx' import { SchemaDocs } from './SchemaDocs.tsx' +import { ApiDocs } from './ApiDocs.tsx' import { ComponentsDocs } from './ComponentsDocs.tsx' import { UserJourneyDocs } from './UserJourneyDocs.tsx' import { NextPhaseDocs } from './NextPhaseDocs.tsx' @@ -18,6 +19,7 @@ export const Docs: React.FC = () => { { id: 'schema', label: 'Data Schema (Current)', icon: Database }, { id: 'workflow', label: 'System Workflow', icon: GitMerge }, { id: 'user_journey', label: 'User Journeys', icon: MapIcon }, + { id: 'api', label: 'Api Docs', icon: AlertCircle }, { id: 'next_phase', label: 'Next Phase & To-Do', icon: ListTodo }, ] @@ -70,6 +72,7 @@ export const Docs: React.FC = () => { {activeTab === 'workflow' && } {activeTab === 'user_journey' && } {activeTab === 'next_phase' && } + {activeTab === 'api' && } From 4a3e9b5c525a378fbef55d993ff68d8e867c5cd4 Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 19 Apr 2026 18:12:44 +0100 Subject: [PATCH 02/11] fix(api): add CROS to server when responding a request --- api/cmd/server/main.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index cbf8a26..d2ac704 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -24,6 +24,21 @@ var ( BuildTime = "unknown" ) +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} func main() { // config setup cfg := config.Load() @@ -105,7 +120,7 @@ func main() { // http server server := &http.Server{ Addr: ":" + cfg.Port, - Handler: mux, + Handler: corsMiddleware(mux), // NOTE: some timeouts to prevent slow clients maybe not needed // time to read the full request ReadTimeout: 5 * time.Second, From 02f8785f421996474fb6f809a6d52ba399a6bddd Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 19 Apr 2026 18:21:15 +0100 Subject: [PATCH 03/11] feat(front): handle authentication by requestion the api --- front/src/context/AuthContext.tsx | 76 +++++++++++--------- front/src/lib/auth.ts | 74 +++++++++++++++++++ front/src/pages/Login.tsx | 114 +++++++++++------------------- 3 files changed, 161 insertions(+), 103 deletions(-) create mode 100644 front/src/lib/auth.ts diff --git a/front/src/context/AuthContext.tsx b/front/src/context/AuthContext.tsx index f71326b..94e692b 100644 --- a/front/src/context/AuthContext.tsx +++ b/front/src/context/AuthContext.tsx @@ -1,66 +1,78 @@ import type { FC, ReactNode } from 'react' import type { User } from '../types/index.ts' import { createContext, useContext, useEffect, useState } from 'react' -import { USERS } from '../data/mockData.ts' +import { authApi, tokenStore, isApiError } from '../lib/auth.ts' interface AuthContextType { user: User | null - login: (id: string) => void - logout: () => void + login: (email: string, password: string) => Promise + logout: () => Promise isLoading: boolean + error: string | null } const AuthContext = createContext(undefined) -/** - * Handles user authentication state and session persistence. - */ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) /** - * Attempt to restore user session from localStorage on initial load. + * On mount, attempt a silent token refresh. + * The HttpOnly refresh token cookie is sent automatically. + * If it fails, the user is unauthenticated — no redirect, just clear state. */ useEffect(() => { - const savedUser = localStorage.getItem('operafix-user') - if (savedUser) { - setUser(JSON.parse(savedUser)) - } - setIsLoading(false) + authApi + .refresh() + .then(({ access_token, user }) => { + tokenStore.set(access_token) + setUser(user) + }) + .catch(() => { + // Expired or no session — expected, not an error + tokenStore.clear() + setUser(null) + }) + .finally(() => setIsLoading(false)) }, []) - /** - * Find user in mock data and save to session. - */ - const login = (id: string) => { - const foundUser = USERS.find((u) => u.id === id) - if (foundUser) { - setUser(foundUser) - localStorage.setItem('operafix-user', JSON.stringify(foundUser)) + const login = async (email: string, password: string) => { + setError(null) + try { + const { access_token, user } = await authApi.login(email, password) + tokenStore.set(access_token) + setUser(user) + } catch (err) { + const message = isApiError(err) + ? err.status === 401 + ? 'Invalid email or password.' + : err.message + : 'Network error. Please try again.' + + setError(message) + throw err // let the form handle loading state } } - /** - * Clear user session. - */ - const logout = () => { + const logout = async () => { + await authApi.logout().catch(() => { + console.log("something when here!!!!!!!!!!!!!!!!!") + }) + tokenStore.clear() setUser(null) - localStorage.removeItem('operafix-user') } return ( - + {children} ) } -/** - * Custom hook to access authentication state. - */ export const useAuth = () => { - const context = useContext(AuthContext) - if (!context) throw new Error('useAuth must be used within AuthProvider') - return context + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx } diff --git a/front/src/lib/auth.ts b/front/src/lib/auth.ts new file mode 100644 index 0000000..73624c1 --- /dev/null +++ b/front/src/lib/auth.ts @@ -0,0 +1,74 @@ +import type { User } from '../types/index' + +const BASE = import.meta.env.VITE_API_URL ?? '' + +// In-memory access token — never touches localStorage/sessionStorage +let _accessToken: string | null = null + +export const tokenStore = { + get: () => _accessToken, + set: (t: string | null) => { _accessToken = t }, + clear: () => { _accessToken = null }, +} + +const request = async (path: string, init: RequestInit = {}): Promise => { + const res = await fetch(`${BASE}${path}`, { + ...init, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(tokenStore.get() ? { Authorization: `Bearer ${tokenStore.get()}` } : {}), + ...init.headers, + }, + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw createApiError(res.status, body.message ?? res.statusText) + } + + return res.json() as Promise +} + +export type ApiError = { + name: 'ApiError' + status: number + message: string +} + +export const createApiError = (status: number, message: string): ApiError => ({ + name: 'ApiError', + status, + message, +}) + +export const isApiError = (err: unknown): err is ApiError => + typeof err === 'object' && err !== null && (err as ApiError).name === 'ApiError' + +interface LoginResponse { + access_token: string + user: User +} + +export const authApi = { + login: (email: string, password: string) => + request('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }), + + refresh: () => + request('/auth/refresh', { method: 'POST' }), + + me: () => + request('/auth/me'), + + logout: () => + request('/auth/logout', { method: 'POST' }), + + magicLink: (email: string) => + request('/auth/magic-link', { + method: 'POST', + body: JSON.stringify({ email }), + }), +} diff --git a/front/src/pages/Login.tsx b/front/src/pages/Login.tsx index a17ad0e..30bbe4e 100644 --- a/front/src/pages/Login.tsx +++ b/front/src/pages/Login.tsx @@ -1,35 +1,38 @@ -import { type FC, type FormEvent, useState } from 'react' +import { useState, type FC, type FormEvent } from 'react' import { useAuth } from '../context/AuthContext.tsx' -import { USERS } from '../data/mockData.ts' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/Card.tsx' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '../components/Card.tsx' import { Button } from '../components/Button.tsx' import { motion } from 'framer-motion' -import { Globe, Moon, QrCode, Sun } from 'lucide-react' -import { Input, Select } from '../components/Input.tsx' +import { QrCode, Sun, Moon, Globe, Loader2 } from 'lucide-react' +import { Input } from '../components/Input.tsx' import { useTranslation } from 'react-i18next' import { useTheme } from '../context/ThemeContext.tsx' -/** - * Authentication page for the application. - * Provides a clean interface for logging in with demo accounts or manual credentials. - * Includes theme and language controls accessible before login. - */ export const Login: FC = () => { const { t, i18n } = useTranslation() - const { login } = useAuth() + const { login, error } = useAuth() const { mode, toggleMode } = useTheme() - const [selected, setSelected] = useState('') + const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) - /** - * Handle the login form submission. - */ - const handleLogin = (e: FormEvent) => { + const handleLogin = async (e: FormEvent) => { e.preventDefault() - if (selected) { - login(selected) + if (!email || !password) return + + setIsSubmitting(true) + try { + await login(email, password) window.location.href = '/' + } finally { + setIsSubmitting(false) } } @@ -84,10 +87,9 @@ export const Login: FC = () => { - {/* Main Login Card */} - - - + + + {t('login.title')} @@ -101,8 +103,9 @@ export const Login: FC = () => { type='email' placeholder='email@example.com' value={email} - onChange={(e) => setEmail(e.target.value)} - autoComplete='email' + onChange={e => setEmail(e.target.value)} + autoComplete="email" + required />
{ type='password' placeholder='••••••••' value={password} - onChange={(e) => setPassword(e.target.value)} - autoComplete='current-password' + onChange={e => setPassword(e.target.value)} + autoComplete="current-password" + required />
- {/* Demo identity selector for testing purposes */} -
-
- -
-
- - Development Bypass - -
-
- - + {error && ( +

+ {error} +

+ )} From 7590ed75939dc47dec8f595988d96ffc84b7b40f Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 19 Apr 2026 23:10:36 +0100 Subject: [PATCH 04/11] feat(api): add function RequireRole(RoleLocationManager, RoleOpsManager, RoleAdmin) on auth structure to protect other routes when called --- api/internal/auth/middleware.go | 50 ++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go index d0b7b62..bc3d5db 100644 --- a/api/internal/auth/middleware.go +++ b/api/internal/auth/middleware.go @@ -9,8 +9,6 @@ import ( "github.com/google/uuid" ) -// contextKey is an unexported type for context keys in this package. -// Prevents collisions with context keys from other packages. type contextKey string const ( @@ -19,12 +17,10 @@ const ( contextKeyRole contextKey = "role" ) -// Middleware validates the JWT from the Authorization header and injects -// the user's identity into the request context. -// -// On success: calls next handler with user identity in context. +// validates the JWT from the Authorization header and injects +// the user's identity into the request context +// On success: calls next handler with user identity in context // On failure: writes 401 and stops the chain. -// // Usage: // // mux.Handle("GET /auth/me", authMiddleware.Require(meHandler)) @@ -32,7 +28,7 @@ type Middleware struct { tokens *tokenService } -// NewMiddleware constructs the auth middleware. +// constructs the auth middleware func NewMiddleware(jwtSecret string, accessExpiry, refreshExpiry time.Duration) *Middleware { return &Middleware{ tokens: newTokenService(jwtSecret, accessExpiry, refreshExpiry), @@ -78,11 +74,34 @@ func (m *Middleware) Require(next http.Handler) http.Handler { }) } -// ─── Context accessors ──────────────────────────────────────────── +// returns middleware that gates access to specific roles +// Must be chained after Require, it reads the role Require injected into context +// +// managerOnly := authMW.RequireRole(RoleLocationManager, RoleOpsManager, RoleAdmin) +// mux.Handle("GET /qr/labels/{id}", authMW.Require(managerOnly(http.HandlerFunc(h.Labels)))) +func (m *Middleware) RequireRole(roles ...Role) func(http.Handler) http.Handler { + allowed := make(map[Role]struct{}, len(roles)) + for _, r := range roles { + allowed[r] = struct{}{} + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role := RoleFromContext(r.Context()) + if _, ok := allowed[role]; !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":"insufficient permissions"}`)) + return + } + next.ServeHTTP(w, r) + }) + } +} + // Call these inside handlers that are wrapped with Require(). // They panic if called without Require() — intentional, surfaces bugs early. - -// UserIDFromContext returns the authenticated user's UUID from context. +// returns the authenticated user's UUID from context. func UserIDFromContext(ctx context.Context) uuid.UUID { id, ok := ctx.Value(contextKeyUserID).(uuid.UUID) if !ok { @@ -91,7 +110,7 @@ func UserIDFromContext(ctx context.Context) uuid.UUID { return id } -// CompanyIDFromContext returns the authenticated user's company UUID from context. +// returns the authenticated user's company UUID from context. func CompanyIDFromContext(ctx context.Context) uuid.UUID { id, ok := ctx.Value(contextKeyCompanyID).(uuid.UUID) if !ok { @@ -100,7 +119,7 @@ func CompanyIDFromContext(ctx context.Context) uuid.UUID { return id } -// RoleFromContext returns the authenticated user's role from context. +// returns the authenticated user's role from context. func RoleFromContext(ctx context.Context) Role { role, ok := ctx.Value(contextKeyRole).(Role) if !ok { @@ -109,10 +128,7 @@ func RoleFromContext(ctx context.Context) Role { return role } -// ─── helpers ────────────────────────────────────────────────────── - -// extractBearerToken parses the Authorization header. -// Expects: "Authorization: Bearer " +// parses the Authorization header. func extractBearerToken(r *http.Request) (string, bool) { header := r.Header.Get("Authorization") if header == "" { From 57968a4a7b63bf4482d7f93d5ad691f9c04942fc Mon Sep 17 00:00:00 2001 From: lee Date: Sat, 25 Apr 2026 18:05:13 +0100 Subject: [PATCH 05/11] refactor(services|api): only equipments should have UUID the rest should be regular integer + fix some issues on the auth route --- SYSTEM_DESIGN_README.md | 15 +-- api/cmd/server/main.go | 33 ++--- api/internal/auth/handler.go | 17 +-- api/internal/auth/middleware.go | 27 ++-- api/internal/auth/repository.go | 127 ++++++++---------- api/internal/auth/service.go | 5 +- api/internal/auth/token.go | 103 +++++++------- api/internal/docs/handler.go | 2 +- api/pkg/config/config.go | 2 + docker-compose.dev.yml | 3 +- docker-compose.prod.yml | 1 + hasura/migrations/003_locations.up.sql | 0 .../migrations/016_industry_templates.up.sql | 0 13 files changed, 151 insertions(+), 184 deletions(-) create mode 100644 hasura/migrations/003_locations.up.sql create mode 100644 hasura/migrations/016_industry_templates.up.sql diff --git a/SYSTEM_DESIGN_README.md b/SYSTEM_DESIGN_README.md index 029eece..1ab42e5 100644 --- a/SYSTEM_DESIGN_README.md +++ b/SYSTEM_DESIGN_README.md @@ -163,14 +163,13 @@ The Go auth service issues JWTs with a `https://hasura.io/jwt/claims` namespace. `company_id` is the outermost filter on every single table. A tenant can never see another tenant's data regardless of role. -| Role | issues — SELECT | INSERT | UPDATE | -| ------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------------------------------- | -| `employee` | `company_id = x-hasura-company-id` AND `reporter_id = x-hasura-user-id` | ✓ own reports only | Comments only | -| `technician` | `company_id = x-hasura-company-id` AND (`assigned_to = x-hasura-user-id` OR `location_id IN x-hasura-location-ids`) | ✗ | Status + notes on assigned issues | -| `location_manager` | `company_id = x-hasura-company-id` AND `location_id IN x-hasura-location-ids` | ✓ | All fields for their locations | -| `ops_manager` | `company_id = x-hasura-company-id` | ✓ | All fields across company | -| `admin` | `company_id = x-hasura-company-id` | ✓ | All fields | -| `super_admin` | No filter | ✓ | All fields — platform operator only | +| Role | issues — SELECT | INSERT | UPDATE | +| ------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------ | --------------------------------- | +| `employee` | `company_id = x-hasura-company-id` AND `reporter_id = x-hasura-user-id` | ✓ own reports only | Comments only | +| `technician` | `company_id = x-hasura-company-id` AND (`assigned_to = x-hasura-user-id` OR `location_id IN x-hasura-location-ids`) | ✗ | Status + notes on assigned issues | +| `location_manager` | `company_id = x-hasura-company-id` AND `location_id IN x-hasura-location-ids` | ✓ | All fields for their locations | +| `ops_manager` | `company_id = x-hasura-company-id` | ✓ | All fields across company | +| `admin` | no filter | ✓ | All fields only for us | > `company_id` is present on every table and verified in Hasura middleware on every request. A misconfigured JWT cannot leak data between tenants. diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index d2ac704..b2b6696 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -24,21 +24,24 @@ var ( BuildTime = "unknown" ) -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization") - w.Header().Set("Access-Control-Allow-Credentials", "true") - - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - - next.ServeHTTP(w, r) - }) +func corsMiddleware(allowedOrigin string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } } + func main() { // config setup cfg := config.Load() @@ -120,7 +123,7 @@ func main() { // http server server := &http.Server{ Addr: ":" + cfg.Port, - Handler: corsMiddleware(mux), + Handler: corsMiddleware(cfg.CORSAllowedOrigin)(mux), // NOTE: some timeouts to prevent slow clients maybe not needed // time to read the full request ReadTimeout: 5 * time.Second, diff --git a/api/internal/auth/handler.go b/api/internal/auth/handler.go index 8be9fc6..9caccad 100644 --- a/api/internal/auth/handler.go +++ b/api/internal/auth/handler.go @@ -3,8 +3,9 @@ package auth import ( "encoding/json" "errors" - "github.com/google/uuid" + "fmt" "net/http" + "strconv" "time" ) @@ -118,14 +119,14 @@ func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { token, ok := extractBearerToken(r) if ok { if claims, err := h.svc.tokens.verify(token); err == nil { - userID, _ := parseUUID(claims.Subject) - _ = h.svc.Logout(r.Context(), userID) + if userID, err := strconv.ParseInt(claims.Subject, 10, 64); err == nil { + _ = h.svc.Logout(r.Context(), userID) + } } } // Always clear the cookie, even if token parsing failed. h.clearRefreshCookie(w) - writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) } @@ -263,8 +264,8 @@ type userDTO struct { func toUserDTO(u *User) userDTO { return userDTO{ - ID: u.ID.String(), - CompanyID: u.CompanyID.String(), + ID: fmt.Sprintf("%d", u.ID), + CompanyID: fmt.Sprintf("%d", u.CompanyID), Email: u.Email, Role: string(u.Role), } @@ -279,7 +280,3 @@ func writeJSON(w http.ResponseWriter, status int, v any) { func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } - -func parseUUID(s string) (uuid.UUID, error) { - return uuid.Parse(s) -} diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go index bc3d5db..0786d72 100644 --- a/api/internal/auth/middleware.go +++ b/api/internal/auth/middleware.go @@ -3,10 +3,9 @@ package auth import ( "context" "net/http" + "strconv" "strings" "time" - - "github.com/google/uuid" ) type contextKey string @@ -18,7 +17,7 @@ const ( ) // validates the JWT from the Authorization header and injects -// the user's identity into the request context +// the users identity into the request context // On success: calls next handler with user identity in context // On failure: writes 401 and stops the chain. // Usage: @@ -35,9 +34,9 @@ func NewMiddleware(jwtSecret string, accessExpiry, refreshExpiry time.Duration) } } -// Require is an http.Handler wrapper that enforces authentication. -// Handlers wrapped with Require can safely call UserIDFromContext — -// the user is guaranteed to be authenticated at that point. +// Require is an http.Handler wrapper that enforces authentication +// Handlers wrapped with Require can safely call UserIDFromContext +// the user is guaranteed to be authenticated at that point func (m *Middleware) Require(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := extractBearerToken(r) @@ -52,13 +51,13 @@ func (m *Middleware) Require(next http.Handler) http.Handler { return } - userID, err := uuid.Parse(claims.Subject) + userID, err := strconv.ParseInt(claims.Subject, 10, 64) if err != nil { writeUnauthorized(w, "invalid token subject") return } - companyID, err := uuid.Parse(claims.HasuraClaims.CompanyID) + companyID, err := strconv.ParseInt(claims.HasuraClaims.CompanyID, 10, 64) if err != nil { writeUnauthorized(w, "invalid company id in token") return @@ -99,11 +98,9 @@ func (m *Middleware) RequireRole(roles ...Role) func(http.Handler) http.Handler } } -// Call these inside handlers that are wrapped with Require(). -// They panic if called without Require() — intentional, surfaces bugs early. -// returns the authenticated user's UUID from context. -func UserIDFromContext(ctx context.Context) uuid.UUID { - id, ok := ctx.Value(contextKeyUserID).(uuid.UUID) +// Call these inside handlers that are wrapped with Require() +func UserIDFromContext(ctx context.Context) int64 { + id, ok := ctx.Value(contextKeyUserID).(int64) if !ok { panic("auth: UserIDFromContext called without Require middleware") } @@ -111,8 +108,8 @@ func UserIDFromContext(ctx context.Context) uuid.UUID { } // returns the authenticated user's company UUID from context. -func CompanyIDFromContext(ctx context.Context) uuid.UUID { - id, ok := ctx.Value(contextKeyCompanyID).(uuid.UUID) +func CompanyIDFromContext(ctx context.Context) int64 { + id, ok := ctx.Value(contextKeyCompanyID).(int64) if !ok { panic("auth: CompanyIDFromContext called without Require middleware") } diff --git a/api/internal/auth/repository.go b/api/internal/auth/repository.go index b354d69..42ddc54 100644 --- a/api/internal/auth/repository.go +++ b/api/internal/auth/repository.go @@ -6,13 +6,12 @@ import ( "fmt" "time" - "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -// Role is the set of valid user roles. Must match the CHECK constraint -// in migration 007_users.up.sql and the Hasura permission rule names. +// Role is the set of valid user roles +// must match the same ones in migration 007_users.up.sql type Role string const ( @@ -21,40 +20,38 @@ const ( RoleLocationManager Role = "location_manager" RoleOpsManager Role = "ops_manager" RoleAdmin Role = "admin" - RoleSuperAdmin Role = "super_admin" ) -// User is the minimal projection of the users table needed for auth. -// We only select what authentication requires — not the full row. +// User is the minimal projection of the users table needed for auth +// We only select what authentication requires no need for all fields type User struct { - ID uuid.UUID - CompanyID uuid.UUID + ID int64 + CompanyID int64 Email string Role Role - PasswordHash *string // nullable — magic-link users have no password - DefaultLocationID *uuid.UUID + PasswordHash *string // nullable: magic-link users have no password + DefaultLocationID *int64 } -// MagicLinkToken represents a pending magic-link login request. +// MagicLinkToken represents a pending magic-link login request type MagicLinkToken struct { - ID uuid.UUID - UserID uuid.UUID + ID int64 + UserID int64 TokenHash string ExpiresAt time.Time UsedAt *time.Time } -// RefreshToken represents a stored refresh token record. -// We store the hash, never the raw token. +// RefreshToken represents a stored refresh token record type RefreshToken struct { - ID uuid.UUID - UserID uuid.UUID + ID int64 + UserID int64 TokenHash string ExpiresAt time.Time RevokedAt *time.Time } -// repository handles all database operations for the auth package. +// repository handles all database operations for the auth package type repository struct { db *pgxpool.Pool } @@ -63,10 +60,7 @@ func newRepository(db *pgxpool.Pool) *repository { return &repository{db: db} } -// ─── User queries ───────────────────────────────────────────────── - -// getUserByEmail fetches a user by email address. -// Returns ErrNotFound if the user does not exist. +// getUserByEmail fetches a user by email address func (r *repository) getUserByEmail(ctx context.Context, email string) (*User, error) { const q = ` SELECT id, company_id, email, role, password_hash, default_location_id @@ -92,8 +86,8 @@ func (r *repository) getUserByEmail(ctx context.Context, email string) (*User, e return &u, nil } -// getUserByID fetches a user by primary key. -func (r *repository) getUserByID(ctx context.Context, id uuid.UUID) (*User, error) { +// getUserByID fetches a user by primary key +func (r *repository) getUserByID(ctx context.Context, id int64) (*User, error) { const q = ` SELECT id, company_id, email, role, password_hash, default_location_id FROM users @@ -118,9 +112,8 @@ func (r *repository) getUserByID(ctx context.Context, id uuid.UUID) (*User, erro return &u, nil } -// updateLastLogin records the current timestamp as the user's last login. -// Called after every successful authentication. -func (r *repository) updateLastLogin(ctx context.Context, userID uuid.UUID) error { +// updateLastLogin records the current timestamp as the user's last login +func (r *repository) updateLastLogin(ctx context.Context, userID int64) error { const q = `UPDATE users SET last_login_at = NOW() WHERE id = $1` _, err := r.db.Exec(ctx, q, userID) if err != nil { @@ -129,15 +122,15 @@ func (r *repository) updateLastLogin(ctx context.Context, userID uuid.UUID) erro return nil } -// getLocationIDsForUser returns the list of location IDs accessible to a user. -// - For employees and location managers: their default location only (for now). -// Phase 2 adds a user_location_assignments join table for multi-location access. -// - For technicians: their assigned locations (same logic for now). -// - For ops_manager and admin: empty slice (Hasura applies no location filter). +// getLocationIDsForUser returns the list of location IDs accessible to a user +// - For employees and location managers: their default location only (for now) +// Phase 2 adds a user_location_assignments join table for multi-location access +// - For technicians: their assigned locations +// - For ops_manager and admin: empty slice (Hasura applies no location filter) func (r *repository) getLocationIDsForUser(ctx context.Context, user *User) ([]string, error) { switch user.Role { - case RoleOpsManager, RoleAdmin, RoleSuperAdmin: - // These roles see all locations — no location filter in JWT claims. + case RoleOpsManager, RoleAdmin: + // These roles see all locations — no location filter in JWT claims return nil, nil } @@ -146,23 +139,20 @@ func (r *repository) getLocationIDsForUser(ctx context.Context, user *User) ([]s } // Phase 1: single default location. - // Phase 2: query user_location_assignments for multi-location list. - return []string{user.DefaultLocationID.String()}, nil + // Phase 2: query user_location_assignments for multi-location list (we don't have for now) + return []string{fmt.Sprintf("%d", *user.DefaultLocationID)}, nil } -// ─── Refresh token queries ───────────────────────────────────────── - -// createRefreshToken inserts a new refresh token record. -// Only the SHA-256 hash of the token is stored — never the raw value. +// createRefreshToken inserts a new refresh token record func (r *repository) createRefreshToken( ctx context.Context, - userID uuid.UUID, + userID int64, tokenHash string, expiresAt time.Time, ) error { const q = ` - INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) - VALUES (gen_random_uuid(), $1, $2, $3)` + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)` _, err := r.db.Exec(ctx, q, userID, tokenHash, expiresAt) if err != nil { @@ -172,8 +162,6 @@ func (r *repository) createRefreshToken( } // getRefreshToken fetches a refresh token by its hash. -// Returns ErrNotFound if absent, ErrTokenExpired if past expiry, -// ErrTokenRevoked if already used. func (r *repository) getRefreshToken(ctx context.Context, tokenHash string) (*RefreshToken, error) { const q = ` SELECT id, user_id, token_hash, expires_at, revoked_at @@ -206,13 +194,11 @@ func (r *repository) getRefreshToken(ctx context.Context, tokenHash string) (*Re return &rt, nil } -// rotateRefreshToken revokes the old token and creates a new one atomically. -// Rotation on every use means a stolen token can only be used once — -// the next use by the legitimate user revokes it and reveals the theft. +// rotateRefreshToken revokes the old token and creates a new one atomically func (r *repository) rotateRefreshToken( ctx context.Context, oldTokenHash string, - userID uuid.UUID, + userID int64, newTokenHash string, expiresAt time.Time, ) error { @@ -233,8 +219,8 @@ func (r *repository) rotateRefreshToken( // Insert the new token const insert = ` - INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) - VALUES (gen_random_uuid(), $1, $2, $3)` + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)` if _, err := tx.Exec(ctx, insert, userID, newTokenHash, expiresAt); err != nil { return fmt.Errorf("rotate refresh token: insert new: %w", err) } @@ -245,9 +231,9 @@ func (r *repository) rotateRefreshToken( return nil } -// revokeAllRefreshTokens invalidates every active refresh token for a user. -// Called on logout to ensure the session is fully terminated. -func (r *repository) revokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error { +// revokeAllRefreshTokens invalidates every active refresh token for a user +// Called on logout to ensure the session is fully terminated +func (r *repository) revokeAllRefreshTokens(ctx context.Context, userID int64) error { const q = ` UPDATE refresh_tokens SET revoked_at = NOW() @@ -259,17 +245,15 @@ func (r *repository) revokeAllRefreshTokens(ctx context.Context, userID uuid.UUI return nil } -// ─── Magic link queries ──────────────────────────────────────────── - -// createMagicLinkToken stores a magic-link token hash with a 15-minute expiry. +// createMagicLinkToken stores a magic-link token hash with a 15-minute expiry func (r *repository) createMagicLinkToken( ctx context.Context, - userID uuid.UUID, + userID int64, tokenHash string, ) error { const q = ` - INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at) - VALUES (gen_random_uuid(), $1, $2, NOW() + INTERVAL '15 minutes')` + INSERT INTO magic_link_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, NOW() + INTERVAL '15 minutes')` _, err := r.db.Exec(ctx, q, userID, tokenHash) if err != nil { @@ -278,16 +262,11 @@ func (r *repository) createMagicLinkToken( return nil } -// consumeMagicLinkToken validates and marks a magic-link token as used. -// Returns the associated user ID if valid. -// A token can only be used once — subsequent attempts return ErrTokenRevoked. -func (r *repository) consumeMagicLinkToken( - ctx context.Context, - tokenHash string, -) (uuid.UUID, error) { +// consumeMagicLinkToken validates and marks a magic-link token as used +func (r *repository) consumeMagicLinkToken(ctx context.Context, tokenHash string) (int64, error) { tx, err := r.db.Begin(ctx) if err != nil { - return uuid.Nil, fmt.Errorf("consume magic link: begin tx: %w", err) + return 0, fmt.Errorf("consume magic link: begin tx: %w", err) } defer tx.Rollback(ctx) @@ -305,27 +284,27 @@ func (r *repository) consumeMagicLinkToken( &token.UsedAt, ) if errors.Is(err, pgx.ErrNoRows) { - return uuid.Nil, ErrNotFound + return 0, ErrNotFound } if err != nil { - return uuid.Nil, fmt.Errorf("consume magic link: fetch: %w", err) + return 0, fmt.Errorf("consume magic link: fetch: %w", err) } if token.UsedAt != nil { - return uuid.Nil, ErrTokenRevoked + return 0, ErrTokenRevoked } if time.Now().UTC().After(token.ExpiresAt) { - return uuid.Nil, ErrTokenExpired + return 0, ErrTokenExpired } // Mark as used const mark = `UPDATE magic_link_tokens SET used_at = NOW() WHERE id = $1` if _, err := tx.Exec(ctx, mark, token.ID); err != nil { - return uuid.Nil, fmt.Errorf("consume magic link: mark used: %w", err) + return 0, fmt.Errorf("consume magic link: mark used: %w", err) } if err := tx.Commit(ctx); err != nil { - return uuid.Nil, fmt.Errorf("consume magic link: commit: %w", err) + return 0, fmt.Errorf("consume magic link: commit: %w", err) } return token.UserID, nil diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go index cb321a1..9365968 100644 --- a/api/internal/auth/service.go +++ b/api/internal/auth/service.go @@ -6,7 +6,6 @@ import ( "fmt" "time" - "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" ) @@ -133,7 +132,7 @@ func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*LoginRe // This is the correct trade-off — revoking JWTs requires a blocklist // (Redis or DB lookup on every request), which adds latency. // For this scale, 15-minute max exposure on logout is acceptable. -func (s *Service) Logout(ctx context.Context, userID uuid.UUID) error { +func (s *Service) Logout(ctx context.Context, userID int64) error { if err := s.repo.revokeAllRefreshTokens(ctx, userID); err != nil { return fmt.Errorf("logout: %w", err) } @@ -143,7 +142,7 @@ func (s *Service) Logout(ctx context.Context, userID uuid.UUID) error { // Me // Me returns the currently authenticated user's profile. // userID is extracted from the validated JWT by the auth middleware. -func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*User, error) { +func (s *Service) Me(ctx context.Context, userID int64) (*User, error) { user, err := s.repo.getUserByID(ctx, userID) if err != nil { return nil, fmt.Errorf("me: %w", err) diff --git a/api/internal/auth/token.go b/api/internal/auth/token.go index 65e06a6..2d8cca5 100644 --- a/api/internal/auth/token.go +++ b/api/internal/auth/token.go @@ -8,13 +8,11 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" ) -// HasuraClaims is the custom namespace Hasura reads from the JWT. +// HasuraClaims is the custom namespace Hasura reads from the JWT // Every field maps directly to a Hasura session variable used in // row-level permission rules across all tables. -// // Hasura permission example for issues table: // // { "company_id": { "_eq": "X-Hasura-Company-Id" } } @@ -26,37 +24,33 @@ type HasuraClaims struct { // LocationIDs is a Postgres array literal: "{loc-01,loc-02}" // Hasura reads this for location-scoped roles (location_manager, technician) - // who can only see data from their assigned locations. - // For ops_manager and admin this is omitted — they see all locations. + // who can only see data from their assigned locations + // For ops_manager and admin this is omitted: they see all locations LocationIDs string `json:"x-hasura-location-ids"` } -// Claims is the full JWT payload — standard registered claims plus -// the Hasura namespace required for row-level security enforcement. +// Claims is the full JWT payload type Claims struct { jwt.RegisteredClaims - // Hasura reads this exact namespace key from the JWT. - // The key must be "https://hasura.io/jwt/claims" — not configurable. + // Hasura reads this exact namespace key from the JWT HasuraClaims HasuraClaims `json:"https://hasura.io/jwt/claims"` } // TokenPair is the result of a successful authentication. -// AccessToken is short-lived and sent in the Authorization header. -// RefreshToken is long-lived and stored in an HttpOnly cookie. type TokenPair struct { - AccessToken string + // AccessToken is short lived and sent in the Authorization header + AccessToken string + // RefreshToken is long lived and stored in an HttpOnly cookie RefreshToken string - - // RefreshTokenHash is stored in the database — never the raw token. - // On refresh we hash the incoming token and compare to this value. + // the hash token in the db RefreshTokenHash string AccessExpiresAt time.Time RefreshExpiresAt time.Time } -// tokenService handles JWT signing and verification. +// signature structure type tokenService struct { secret []byte accessExpiry time.Duration @@ -71,23 +65,44 @@ func newTokenService(secret string, accessExpiry, refreshExpiry time.Duration) * } } -// issueTokenPair creates a new access + refresh token pair for the given user. -// Called on login and on magic-link verification. +// buildHasuraClaims constructs the Hasura permission claims based on role +func (ts *tokenService) buildHasuraClaims(user *User, locationIDs []string) HasuraClaims { + claims := HasuraClaims{ + AllowedRoles: []string{string(user.Role)}, + DefaultRole: string(user.Role), + UserID: fmt.Sprintf("%d", user.ID), + CompanyID: fmt.Sprintf("%d", user.CompanyID), + } + + // location-scoped roles get an explicit list of accessible locations + switch user.Role { + case RoleLocationManager, RoleTechnician, RoleEmployee: + if len(locationIDs) > 0 { + claims.LocationIDs = toPGArray(locationIDs) + } + } + + return claims +} + +// issueTokenPair creates a new access + refresh token pair for the given user +// Called on login and on magic-link verification func (ts *tokenService) issueTokenPair(user *User, locationIDs []string) (*TokenPair, error) { now := time.Now().UTC() accessExp := now.Add(ts.accessExpiry) refreshExp := now.Add(ts.refreshExpiry) - // Build Hasura claims from the user's role and location assignments + // build Hasura claims from the user's role and location assignments hasuraClaims := ts.buildHasuraClaims(user, locationIDs) - // Sign the access token + // sign the access token accessToken, err := ts.signAccessToken(user.ID, hasuraClaims, now, accessExp) if err != nil { return nil, fmt.Errorf("issue token pair: sign access token: %w", err) } - // Generate a cryptographically random refresh token + // random cryptographically refresh token + // this refresh token allows us to refresh the token easily rawRefresh, err := generateSecureToken(32) if err != nil { return nil, fmt.Errorf("issue token pair: generate refresh token: %w", err) @@ -102,15 +117,15 @@ func (ts *tokenService) issueTokenPair(user *User, locationIDs []string) (*Token }, nil } -// signAccessToken builds and signs the JWT with HS256. +// signAccessToken builds and signs the JWT func (ts *tokenService) signAccessToken( - userID uuid.UUID, + userID int64, hasuraClaims HasuraClaims, now, exp time.Time, ) (string, error) { claims := Claims{ RegisteredClaims: jwt.RegisteredClaims{ - Subject: userID.String(), + Subject: fmt.Sprintf("%d", userID), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(exp), Issuer: "operafix-api", @@ -126,16 +141,16 @@ func (ts *tokenService) signAccessToken( return signed, nil } -// verify parses and validates a JWT string, returning the claims if valid. +// verify parses and validates a JWT string, returning the claims if valid func (ts *tokenService) verify(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims( tokenString, &Claims{}, func(t *jwt.Token) (any, error) { - // Reject tokens signed with any algorithm other than HS256. + // Reject tokens signed with any algorithm other than HS256 // Prevents the "algorithm confusion" attack where an attacker // switches to RS256 and tricks the server into accepting a - // token signed with the public key as the HMAC secret. + // token signed with the public key as the HMAC secret if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } @@ -154,32 +169,8 @@ func (ts *tokenService) verify(tokenString string) (*Claims, error) { return claims, nil } -// buildHasuraClaims constructs the Hasura permission claims based on role. -// The claims determine what data the user can read and write via GraphQL. -func (ts *tokenService) buildHasuraClaims(user *User, locationIDs []string) HasuraClaims { - claims := HasuraClaims{ - AllowedRoles: []string{string(user.Role)}, - DefaultRole: string(user.Role), - UserID: user.ID.String(), - CompanyID: user.CompanyID.String(), - } - - // Location-scoped roles get an explicit list of accessible location IDs. - // ops_manager and admin see all locations — no location filter applied. - switch user.Role { - case RoleLocationManager, RoleTechnician, RoleEmployee: - if len(locationIDs) > 0 { - claims.LocationIDs = toPGArray(locationIDs) - } - } - - return claims -} - -// ─── helpers ────────────────────────────────────────────────────── - // generateSecureToken returns a cryptographically random hex string -// of the given byte length (output is 2× bytes in hex chars). +// of the given byte length (output is 2x bytes in hex chars) func generateSecureToken(bytes int) (string, error) { b := make([]byte, bytes) if _, err := rand.Read(b); err != nil { @@ -188,17 +179,15 @@ func generateSecureToken(bytes int) (string, error) { return hex.EncodeToString(b), nil } -// hashToken SHA-256 hashes a token for safe storage in the database. -// We never store raw refresh tokens — only their hashes. -// On verification: hash the incoming token and compare to the stored hash. +// hashToken, hashes a token for safe storage in the database func hashToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) } -// toPGArray converts a Go string slice to a Postgres array literal. +// toPGArray converts a Go string slice to a Postgres array literal // e.g. ["a","b"] → "{a,b}" -// This format is what Hasura expects for x-hasura-location-ids. +// This format is what Hasura expects for x-hasura-location-ids func toPGArray(ids []string) string { if len(ids) == 0 { return "{}" diff --git a/api/internal/docs/handler.go b/api/internal/docs/handler.go index 0d47b0f..abd163e 100644 --- a/api/internal/docs/handler.go +++ b/api/internal/docs/handler.go @@ -307,7 +307,7 @@ func openAPISpec() map[string]any { "email": map[string]any{"type": "string", "format": "email", "example": "admin@test.com"}, "role": map[string]any{ "type": "string", - "enum": []string{"employee", "technician", "location_manager", "ops_manager", "admin", "super_admin"}, + "enum": []string{"employee", "technician", "location_manager", "ops_manager", "admin"}, "example": "admin", }, }, diff --git a/api/pkg/config/config.go b/api/pkg/config/config.go index 7b28729..47f3618 100644 --- a/api/pkg/config/config.go +++ b/api/pkg/config/config.go @@ -25,6 +25,7 @@ type Config struct { JWTAccessExpiry time.Duration JWTRefreshExpiry time.Duration + CORSAllowedOrigin string // // Email (Postmark) // PostmarkAPIKey string // PostmarkFrom string @@ -45,6 +46,7 @@ func Load() *Config { JWTSecret: require("JWT_SECRET"), JWTAccessExpiry: parseDuration("JWT_ACCESS_EXPIRY", 15*time.Minute), JWTRefreshExpiry: parseDuration("JWT_REFRESH_EXPIRY", 7*24*time.Hour), + CORSAllowedOrigin: getEnv("CORS_ALLOWED_ORIGIN", "http://localhost:8081"), // // External services — optional locally, required in production. // // The service checks these at call time and skips gracefully if empty. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 141275a..6fb608f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,7 +20,7 @@ services: - postgres_data:/var/lib/postgresql # init-postgres.sql runs ONCE on first boot (when the volume is empty) # it enables PostgreSQL extensions that require superuser privileges: - # pgcrypto → gen_random_uuid() used by every table's PK default + # pgcrypto → gen_random_uuid() used for the equipment table # pg_trgm → fast fuzzy search on equipment names and issue titles # btree_gist → exclusion constraints for PM schedule overlap prevention - ./scripts/init-postgres.sql:/docker-entrypoint-initdb.d/00-init.sql:ro @@ -112,6 +112,7 @@ services: # 7 days, stored in HttpOnly cookie JWT_REFRESH_EXPIRY: ${JWT_REFRESH_EXPIRY:-168h} APP_ENV: development + CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:5173} LOG_LEVEL: debug PORT: 8080 # # External services — leave blank locally unless actively testing diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index db28436..40f1c3c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -97,6 +97,7 @@ services: JWT_SECRET: ${JWT_SECRET} JWT_ACCESS_EXPIRY: ${JWT_ACCESS_EXPIRY:-15m} JWT_REFRESH_EXPIRY: ${JWT_REFRESH_EXPIRY:-168h} + CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:5173} APP_ENV: production LOG_LEVEL: info PORT: 8080 diff --git a/hasura/migrations/003_locations.up.sql b/hasura/migrations/003_locations.up.sql new file mode 100644 index 0000000..e69de29 diff --git a/hasura/migrations/016_industry_templates.up.sql b/hasura/migrations/016_industry_templates.up.sql new file mode 100644 index 0000000..e69de29 From c4b7103f5a28907895a899dd93f03a58a1e279d9 Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 26 Apr 2026 19:48:09 +0100 Subject: [PATCH 06/11] feat(front): fully add graphql endpoint to the front end, now we can query the db using grahpql from the front. Including the token for role permission check feat(hasura): add permissions roles for all tables refactor(api): fix some compatability with front --- api/internal/auth/handler.go | 48 ++- api/internal/auth/middleware.go | 2 +- api/internal/auth/repository.go | 8 +- api/internal/auth/service.go | 1 - api/internal/auth/token.go | 8 +- front/package-lock.json | 70 +++++ front/package.json | 2 + front/src/context/AuthContext.tsx | 2 +- front/src/lib/auth.ts | 25 +- front/src/lib/equipment.ts | 74 +++++ front/src/lib/graphql-client.ts | 51 ++++ front/src/lib/locations.ts | 31 ++ front/src/main.tsx | 16 +- front/src/pages/Docs/ApiDocs.tsx | 153 ++++++---- front/src/pages/Equipment/EquipmentList.tsx | 276 ++++++++++-------- front/src/types/index.ts | 26 +- .../tables/public_industry_templates.yaml | 1 - .../tables/public_schema_migrations.yaml | 3 + 18 files changed, 564 insertions(+), 233 deletions(-) create mode 100644 front/src/lib/equipment.ts create mode 100644 front/src/lib/graphql-client.ts create mode 100644 front/src/lib/locations.ts create mode 100644 hasura/metadata/databases/default/tables/public_schema_migrations.yaml diff --git a/api/internal/auth/handler.go b/api/internal/auth/handler.go index 9caccad..db5112c 100644 --- a/api/internal/auth/handler.go +++ b/api/internal/auth/handler.go @@ -3,7 +3,6 @@ package auth import ( "encoding/json" "errors" - "fmt" "net/http" "strconv" "time" @@ -38,10 +37,10 @@ type loginRequest struct { } type loginResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - User userDTO `json:"user"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + User AuthResponse `json:"user"` } func (h *Handler) login(w http.ResponseWriter, r *http.Request) { @@ -76,7 +75,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) { AccessToken: result.AccessToken, TokenType: "Bearer", ExpiresIn: int(15 * time.Minute / time.Second), - User: toUserDTO(result.User), + User: toAuthResponse(result.User), }) } @@ -108,7 +107,7 @@ func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) { AccessToken: result.AccessToken, TokenType: "Bearer", ExpiresIn: int(15 * time.Minute / time.Second), - User: toUserDTO(result.User), + User: toAuthResponse(result.User), }) } @@ -132,7 +131,7 @@ func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { // GET /auth/me type meResponse struct { - User userDTO `json:"user"` + User AuthResponse `json:"user"` } func (h *Handler) me(w http.ResponseWriter, r *http.Request) { @@ -148,7 +147,7 @@ func (h *Handler) me(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, meResponse{User: toUserDTO(user)}) + writeJSON(w, http.StatusOK, meResponse{User: toAuthResponse(user)}) } //POST /auth/magic-link @@ -213,7 +212,7 @@ func (h *Handler) verifyMagicLink(w http.ResponseWriter, r *http.Request) { AccessToken: result.AccessToken, TokenType: "Bearer", ExpiresIn: int(15 * time.Minute / time.Second), - User: toUserDTO(result.User), + User: toAuthResponse(result.User), }) } @@ -251,26 +250,23 @@ func (h *Handler) clearRefreshCookie(w http.ResponseWriter) { }) } -// DTOs - -// userDTO is the public-facing user representation. -// Never expose password_hash — not even its existence. -type userDTO struct { - ID string `json:"id"` - CompanyID string `json:"company_id"` - Email string `json:"email"` - Role string `json:"role"` +type AuthResponse struct { + ID int64 `json:"id"` + CompanyID int64 `json:"companyId"` + Email string `json:"email"` + Role Role `json:"role"` + DefaultLocationID *int64 `json:"defaultLocationId,omitempty"` } -func toUserDTO(u *User) userDTO { - return userDTO{ - ID: fmt.Sprintf("%d", u.ID), - CompanyID: fmt.Sprintf("%d", u.CompanyID), - Email: u.Email, - Role: string(u.Role), +func toAuthResponse(u *User) AuthResponse { + return AuthResponse{ + ID: u.ID, + CompanyID: u.CompanyId, + Email: u.Email, + Role: u.Role, + DefaultLocationID: u.DefaultLocationID, } } - func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go index 0786d72..602b4f8 100644 --- a/api/internal/auth/middleware.go +++ b/api/internal/auth/middleware.go @@ -57,7 +57,7 @@ func (m *Middleware) Require(next http.Handler) http.Handler { return } - companyID, err := strconv.ParseInt(claims.HasuraClaims.CompanyID, 10, 64) + companyID, err := strconv.ParseInt(claims.HasuraClaims.CompanyId, 10, 64) if err != nil { writeUnauthorized(w, "invalid company id in token") return diff --git a/api/internal/auth/repository.go b/api/internal/auth/repository.go index 42ddc54..bd246fb 100644 --- a/api/internal/auth/repository.go +++ b/api/internal/auth/repository.go @@ -26,10 +26,10 @@ const ( // We only select what authentication requires no need for all fields type User struct { ID int64 - CompanyID int64 + CompanyId int64 Email string Role Role - PasswordHash *string // nullable: magic-link users have no password + PasswordHash *string DefaultLocationID *int64 } @@ -71,7 +71,7 @@ func (r *repository) getUserByEmail(ctx context.Context, email string) (*User, e var u User err := r.db.QueryRow(ctx, q, email).Scan( &u.ID, - &u.CompanyID, + &u.CompanyId, &u.Email, &u.Role, &u.PasswordHash, @@ -97,7 +97,7 @@ func (r *repository) getUserByID(ctx context.Context, id int64) (*User, error) { var u User err := r.db.QueryRow(ctx, q, id).Scan( &u.ID, - &u.CompanyID, + &u.CompanyId, &u.Email, &u.Role, &u.PasswordHash, diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go index 9365968..86b2ed7 100644 --- a/api/internal/auth/service.go +++ b/api/internal/auth/service.go @@ -71,7 +71,6 @@ func (s *Service) Login(ctx context.Context, input LoginInput) (*LoginResult, er if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(input.Password)); err != nil { return nil, ErrUnauthorized } - return s.issueSession(ctx, user) } diff --git a/api/internal/auth/token.go b/api/internal/auth/token.go index 2d8cca5..361264c 100644 --- a/api/internal/auth/token.go +++ b/api/internal/auth/token.go @@ -19,8 +19,8 @@ import ( type HasuraClaims struct { AllowedRoles []string `json:"x-hasura-allowed-roles"` DefaultRole string `json:"x-hasura-default-role"` - UserID string `json:"x-hasura-user-id"` - CompanyID string `json:"x-hasura-company-id"` + UserId string `json:"x-hasura-user-id"` + CompanyId string `json:"x-hasura-company-id"` // LocationIDs is a Postgres array literal: "{loc-01,loc-02}" // Hasura reads this for location-scoped roles (location_manager, technician) @@ -70,8 +70,8 @@ func (ts *tokenService) buildHasuraClaims(user *User, locationIDs []string) Hasu claims := HasuraClaims{ AllowedRoles: []string{string(user.Role)}, DefaultRole: string(user.Role), - UserID: fmt.Sprintf("%d", user.ID), - CompanyID: fmt.Sprintf("%d", user.CompanyID), + UserId: fmt.Sprintf("%d", user.ID), + CompanyId: fmt.Sprintf("%d", user.CompanyId), } // location-scoped roles get an explicit list of accessible locations diff --git a/front/package-lock.json b/front/package-lock.json index 5aae6b2..b22b4af 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -11,6 +11,7 @@ "@vitejs/plugin-react": "^4.3.4", "clsx": "^2.1.1", "framer-motion": "^12.38.0", + "graphql": "^16.13.2", "i18next": "^26.0.4", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.477.0", @@ -21,6 +22,7 @@ "react-router-dom": "^7.13.2", "recharts": "^2.15.4", "tailwind-merge": "^3.5.0", + "urql": "^5.0.2", "vite": "^6.4.2", "vite-plugin-pwa": "^1.2.0" }, @@ -34,6 +36,20 @@ "typescript": "^5.9.3" } }, + "node_modules/@0no-co/graphql.web": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", + "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -91,6 +107,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2566,6 +2583,7 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2576,6 +2594,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2602,6 +2621,16 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@urql/core": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-6.0.1.tgz", + "integrity": "sha512-FZDiQk6jxbj5hixf2rEPv0jI+IZz0EqqGW8mJBEug68/zHTtT+f34guZDmyjJZyiWbj0vL165LoMr/TkeDHaug==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.13", + "wonka": "^6.3.2" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", @@ -2638,6 +2667,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2920,6 +2950,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4082,6 +4113,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4188,6 +4229,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -4707,6 +4749,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5224,6 +5267,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5440,6 +5484,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5449,6 +5494,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6392,6 +6438,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -6577,6 +6624,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6711,6 +6759,20 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/urql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/urql/-/urql-5.0.2.tgz", + "integrity": "sha512-hiBR9GNbMPMZpv9Yd40EMCc94d8eAkGcmt5jcrKVfp26ScjluAQLCEKetJ4SXLy5DJG59Y6gbuA+2yquzh20/w==", + "license": "MIT", + "dependencies": { + "@urql/core": "^6.0.1", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^6.0.0", + "react": ">= 16.8.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -6754,6 +6816,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6979,6 +7042,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wonka": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", + "license": "MIT" + }, "node_modules/workbox-background-sync": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", @@ -7140,6 +7209,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/front/package.json b/front/package.json index 7ac674a..13bfd43 100644 --- a/front/package.json +++ b/front/package.json @@ -12,6 +12,7 @@ "@vitejs/plugin-react": "^4.3.4", "clsx": "^2.1.1", "framer-motion": "^12.38.0", + "graphql": "^16.13.2", "i18next": "^26.0.4", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.477.0", @@ -22,6 +23,7 @@ "react-router-dom": "^7.13.2", "recharts": "^2.15.4", "tailwind-merge": "^3.5.0", + "urql": "^5.0.2", "vite": "^6.4.2", "vite-plugin-pwa": "^1.2.0" }, diff --git a/front/src/context/AuthContext.tsx b/front/src/context/AuthContext.tsx index 94e692b..40a6f9e 100644 --- a/front/src/context/AuthContext.tsx +++ b/front/src/context/AuthContext.tsx @@ -58,7 +58,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const logout = async () => { await authApi.logout().catch(() => { - console.log("something when here!!!!!!!!!!!!!!!!!") + console.log('something when here!!!!!!!!!!!!!!!!!') }) tokenStore.clear() setUser(null) diff --git a/front/src/lib/auth.ts b/front/src/lib/auth.ts index 73624c1..adb10b1 100644 --- a/front/src/lib/auth.ts +++ b/front/src/lib/auth.ts @@ -7,8 +7,12 @@ let _accessToken: string | null = null export const tokenStore = { get: () => _accessToken, - set: (t: string | null) => { _accessToken = t }, - clear: () => { _accessToken = null }, + set: (t: string | null) => { + _accessToken = t + }, + clear: () => { + _accessToken = null + }, } const request = async (path: string, init: RequestInit = {}): Promise => { @@ -17,7 +21,9 @@ const request = async (path: string, init: RequestInit = {}): Promise => { credentials: 'include', headers: { 'Content-Type': 'application/json', - ...(tokenStore.get() ? { Authorization: `Bearer ${tokenStore.get()}` } : {}), + ...(tokenStore.get() + ? { Authorization: `Bearer ${tokenStore.get()}` } + : {}), ...init.headers, }, }) @@ -43,7 +49,9 @@ export const createApiError = (status: number, message: string): ApiError => ({ }) export const isApiError = (err: unknown): err is ApiError => - typeof err === 'object' && err !== null && (err as ApiError).name === 'ApiError' + typeof err === 'object' && + err !== null && + (err as ApiError).name === 'ApiError' interface LoginResponse { access_token: string @@ -57,14 +65,11 @@ export const authApi = { body: JSON.stringify({ email, password }), }), - refresh: () => - request('/auth/refresh', { method: 'POST' }), + refresh: () => request('/auth/refresh', { method: 'POST' }), - me: () => - request('/auth/me'), + me: () => request('/auth/me'), - logout: () => - request('/auth/logout', { method: 'POST' }), + logout: () => request('/auth/logout', { method: 'POST' }), magicLink: (email: string) => request('/auth/magic-link', { diff --git a/front/src/lib/equipment.ts b/front/src/lib/equipment.ts new file mode 100644 index 0000000..03fad65 --- /dev/null +++ b/front/src/lib/equipment.ts @@ -0,0 +1,74 @@ +import { gql, useQuery, type UseQueryArgs } from 'urql' + +export interface EquipmentRow { + id: string + name: string + serial_number: string + qr_code_id: string + status: 'active' | 'under_repair' | 'decommissioned' + install_date: string + location: { id: number; name: string } + equipment_category: { id: number; name: string } +} + +interface EquipmentListData { + equipment: EquipmentRow[] +} + +interface EquipmentListVariables { + where: Record +} + +interface EquipmentCategory { + id: number + name: string +} + +interface EquipmentCategoriesData { + equipment_categories: EquipmentCategory[] +} + +interface EquipmentCategoriesVariables { + company_id: number +} + +const EQUIPMENT_LIST_QUERY = gql` + query EquipmentList($where: equipment_bool_exp!) { + equipment(where: $where, order_by: { name: asc }) { + id + name + serial_number + qr_code_id + status + install_date + location { id name } + equipment_category { id name } + } + }` + +const EQUIPMENT_CATEGORIES_QUERY = gql` + query EquipmentCategories($company_id: Int!) { + equipment_categories( + where: { company_id: { _eq: $company_id } } + order_by: { name: asc } + ) { + id + name + } + }` + +export const useGetEquipmentList = ( + props: Omit, 'query'>, +) => + useQuery({ + query: EQUIPMENT_LIST_QUERY, + ...props, + }) + +export const useGetEquipmentCategories = ( + props: Omit, 'query'>, +) => + useQuery({ + query: EQUIPMENT_CATEGORIES_QUERY, + ...props, + }) diff --git a/front/src/lib/graphql-client.ts b/front/src/lib/graphql-client.ts new file mode 100644 index 0000000..ec56fbd --- /dev/null +++ b/front/src/lib/graphql-client.ts @@ -0,0 +1,51 @@ +import { createClient, cacheExchange } from 'urql' +import { makeResult, makeErrorResult, type Exchange } from '@urql/core' +import { print } from 'graphql' +import { pipe, mergeMap, fromPromise, fromValue } from 'wonka' + +// token was injected on the main.ts by the auth.ts +let _getToken: () => string | null = () => null + +export const setTokenGetter = (fn: () => string | null): void => { + _getToken = fn +} + +const plainFetchExchange: Exchange = () => ops$ => + pipe( + ops$, + mergeMap(op => { + if (op.kind !== 'query' && op.kind !== 'mutation') { + return fromValue({ + operation: op, + data: undefined, + error: undefined, + extensions: undefined, + }) + } + + const token = _getToken() + + const fetcher = fetch(import.meta.env.VITE_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + query: print(op.query), + variables: op.variables, + operationName: op.operationName, + }), + }) + .then(res => res.json()) + .then(result => makeResult(op, result)) + .catch(err => makeErrorResult(op, err)) + + return fromPromise(fetcher) + }), + ) + +export const gqlClient = createClient({ + url: import.meta.env.VITE_GRAPHQL_URL, + exchanges: [cacheExchange, plainFetchExchange], +}) diff --git a/front/src/lib/locations.ts b/front/src/lib/locations.ts new file mode 100644 index 0000000..593ac5b --- /dev/null +++ b/front/src/lib/locations.ts @@ -0,0 +1,31 @@ +import { gql, useQuery, type UseQueryArgs } from 'urql' + +interface Location { + id: number + name: string +} + +interface LocationsData { + locations: Location[] +} + +interface LocationsVariables { + company_id: number +} + +const LOCATIONS_QUERY = gql` + query Locations($company_id: Int!) { + locations(where: { company_id: { _eq: $company_id } }, order_by: { name: asc }) { + id + name + } + } +` + +export const useGetLocations = ( + props: Omit, 'query'>, +) => + useQuery({ + query: LOCATIONS_QUERY, + ...props, + }) diff --git a/front/src/main.tsx b/front/src/main.tsx index 73ddfd7..e44ecf4 100644 --- a/front/src/main.tsx +++ b/front/src/main.tsx @@ -1,9 +1,15 @@ -import React from 'react' +import { StrictMode } from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' +import { Provider as UrqlProvider } from 'urql' +import { gqlClient, setTokenGetter } from './lib/graphql-client.ts' +import { tokenStore } from './lib/auth.ts' import './index.css' import './i18n' +// get the jwt token so that we can pass it to the graphql endpoint +setTokenGetter(() => tokenStore.get()) + /** * Root entry point for the React application. * Initialises the app within a StrictMode wrapper for development best practices. @@ -11,8 +17,10 @@ import './i18n' const rootElement = document.getElementById('root') if (rootElement) { ReactDOM.createRoot(rootElement).render( - - - , + + + + + , ) } diff --git a/front/src/pages/Docs/ApiDocs.tsx b/front/src/pages/Docs/ApiDocs.tsx index a1421f8..f07f4e2 100644 --- a/front/src/pages/Docs/ApiDocs.tsx +++ b/front/src/pages/Docs/ApiDocs.tsx @@ -74,10 +74,10 @@ interface SecurityScheme { } const METHOD_STYLES: Record = { - get: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', - post: 'bg-blue-500/10 text-blue-400 border-blue-500/20', - put: 'bg-amber-500/10 text-amber-400 border-amber-500/20', - patch: 'bg-orange-500/10 text-orange-400 border-orange-500/20', + get: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', + post: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + put: 'bg-amber-500/10 text-amber-400 border-amber-500/20', + patch: 'bg-orange-500/10 text-orange-400 border-orange-500/20', delete: 'bg-red-500/10 text-red-400 border-red-500/20', } @@ -92,7 +92,8 @@ function MethodBadge({ method }: { method: string }) { {method.toUpperCase()} @@ -113,12 +114,22 @@ function CopyButton({ text }: { text: string }) { onClick={copy} className="p-1.5 rounded-md hover:bg-surface-container transition-colors text-on-surface-variant hover:text-on-surface" > - {copied ? : } + {copied ? ( + + ) : ( + + )} ) } -function CodeBlock({ children, language = 'json' }: { children: string; language?: string }) { +function CodeBlock({ + children, + language = 'json', +}: { + children: string + language?: string +}) { return (
@@ -156,7 +167,9 @@ function EndpointRow({ if (resolved?.properties) { const ex: Record = {} for (const [k, v] of Object.entries(resolved.properties)) { - ex[k] = v.example ?? (v.type === 'string' ? '' : v.type === 'integer' ? 0 : null) + ex[k] = + v.example ?? + (v.type === 'string' ? '' : v.type === 'integer' ? 0 : null) } return JSON.stringify(ex, null, 2) } @@ -193,7 +206,11 @@ function EndpointRow({
{isSecured && ( - + )} - {/* Description + auth badge */}

@@ -226,10 +242,8 @@ function EndpointRow({

- {/* Left — Parameters + Request body */}
- {/* Parameters */} {(operation.parameters?.length ?? 0) > 0 && (
@@ -238,7 +252,10 @@ function EndpointRow({

{operation.parameters!.map(param => ( -
+
@@ -253,7 +270,9 @@ function EndpointRow({ )}
-

{param.description}

+

+ {param.description} +

{param.schema?.type} @@ -283,8 +302,12 @@ function EndpointRow({ {[ `curl -X ${method.toUpperCase()} http://localhost:8081${path}`, isSecured ? ` -H "Authorization: Bearer "` : null, - requestExample ? ` -H "Content-Type: application/json"` : null, - requestExample ? ` -d '${requestExample.replace(/\n/g, ' ')}'` : null, + requestExample + ? ` -H "Content-Type: application/json"` + : null, + requestExample + ? ` -d '${requestExample.replace(/\n/g, ' ')}'` + : null, ] .filter(Boolean) .join(' \\\n')} @@ -298,38 +321,47 @@ function EndpointRow({ Responses

- {Object.entries(operation.responses).map(([status, response]) => ( -
-
- - {status} - - {response.description} -
- {response.content && ( -
- {Object.entries(response.content).map(([mime, content]) => ( -
- {content.example ? ( -
-                                {JSON.stringify(content.example, null, 2)}
-                              
- ) : content.schema?.$ref ? ( - - → {content.schema.$ref.split('/').pop()} - - ) : null} -
- ))} + {Object.entries(operation.responses).map( + ([status, response]) => ( +
+
+ + {status} + + + {response.description} +
- )} -
- ))} + {response.content && ( +
+ {Object.entries(response.content).map( + ([mime, content]) => ( +
+ {content.example ? ( +
+                                    {JSON.stringify(content.example, null, 2)}
+                                  
+ ) : content.schema?.$ref ? ( + + → {content.schema.$ref.split('/').pop()} + + ) : null} +
+ ), + )} +
+ )} +
+ ), + )}
@@ -367,7 +399,9 @@ const TagSection = ({

{tag.name}

- {tag.description} + + {tag.description} + {endpoints.length} endpoint{endpoints.length !== 1 ? 's' : ''} @@ -415,7 +449,9 @@ export const ApiDocs: FC = () => { return (
- Loading spec... + + Loading spec... +
) } @@ -436,7 +472,10 @@ export const ApiDocs: FC = () => { const securitySchemes = spec.components?.securitySchemes ?? {} // Group endpoints by tag - const endpointsByTag: Record> = {} + const endpointsByTag: Record< + string, + Array<{ method: string; path: string; operation: Operation }> + > = {} for (const [path, methods] of Object.entries(spec.paths)) { for (const [method, operation] of Object.entries(methods)) { @@ -448,7 +487,6 @@ export const ApiDocs: FC = () => { return (
- {/* Header */}
@@ -472,12 +510,17 @@ export const ApiDocs: FC = () => { key={server.url} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface border border-border" > - +

{server.description}

- {server.url} + + {server.url} +
))} @@ -496,7 +539,9 @@ export const ApiDocs: FC = () => {
- {name} + + {name} + {scheme.scheme ?? scheme.type} diff --git a/front/src/pages/Equipment/EquipmentList.tsx b/front/src/pages/Equipment/EquipmentList.tsx index df2ef88..f22e45b 100644 --- a/front/src/pages/Equipment/EquipmentList.tsx +++ b/front/src/pages/Equipment/EquipmentList.tsx @@ -1,7 +1,6 @@ -import { type FC, useMemo, useState } from 'react' +import { useState, useEffect, type FC } from 'react' import { useTranslation } from 'react-i18next' -import { CATEGORIES, EQUIPMENT, LOCATIONS } from '../../data/mockData.ts' -import { Card, CardContent, CardHeader } from '../../components/Card.tsx' +import { Card, CardContent, CardHeader } from '../../components/Card' import { Table, TableBody, @@ -15,50 +14,80 @@ import { Button } from '../../components/Button.tsx' import { Input, Select } from '../../components/Input.tsx' import { ChevronRight, Plus, QrCode } from 'lucide-react' import { useNavigate } from 'react-router-dom' -import { useAuth } from '../../context/AuthContext.tsx' +import { useAuth } from '../../context/AuthContext' +import { + useGetEquipmentList, + useGetEquipmentCategories, +} from '../../lib/equipment' +import { useGetLocations } from '../../lib/locations' +// debounce on search +export function useDebounce(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value) + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delay) + return () => clearTimeout(t) + }, [value, delay]) + return debounced +} /** * Fleet inventory overview. - * Provides advanced filtering and searching for all registered assets. + * Fetches live data from Hasura; filtering is server-side via GraphQL variables. */ export const EquipmentList: FC = () => { const navigate = useNavigate() const { t } = useTranslation() - const { user } = useAuth() + const { user, isLoading } = useAuth() + + // TODO: add a loader? + if (isLoading) return null + const [searchTerm, setSearchTerm] = useState('') - const [locationFilter, setLocationFilter] = useState('all') - const [statusFilter, setStatusFilter] = useState('all') - const [typeFilter, setTypeFilter] = useState('all') + const [locationFilter, setLocationFilter] = useState(null) + const [statusFilter, setStatusFilter] = useState(null) + const [categoryFilter, setCategoryFilter] = useState(null) + const debouncedSearch = useDebounce(searchTerm, 300) + + const where: Record = { + company_id: { _eq: user!.companyId }, + } + if (locationFilter) where.location_id = { _eq: locationFilter } + if (categoryFilter) where.category_id = { _eq: categoryFilter } + if (statusFilter) where.status = { _eq: statusFilter } + if (debouncedSearch) { + const term = `%${debouncedSearch}%` + where._or = [ + { name: { _ilike: term } }, + { serial_number: { _ilike: term } }, + { qr_code_id: { _ilike: term } }, + ] + } + const [{ data, fetching, error }] = useGetEquipmentList({ + variables: { where }, + pause: !user, + }) const isAdminOrManager = user?.role === 'admin' || user?.role === 'ops_manager' || user?.role === 'manager' - /** - * Filter the equipment list based on user input and selected filters. - */ - const filteredEquipment = useMemo( - () => - EQUIPMENT.filter((item) => { - const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) || - item.serialNumber.toLowerCase().includes(searchTerm.toLowerCase()) || - item.qrCodeId.toLowerCase().includes(searchTerm.toLowerCase()) - const matchesLocation = locationFilter === 'all' || - item.locationId === locationFilter - const matchesStatus = statusFilter === 'all' || - item.status === statusFilter - const matchesType = typeFilter === 'all' || - item.categoryId === typeFilter + // TODO: handle fetching and error ? + const [{ data: locationsData }] = useGetLocations({ + variables: { company_id: user!.companyId }, + pause: !user, + }) + const [{ data: categoriesData }] = useGetEquipmentCategories({ + variables: { company_id: user!.companyId }, + pause: !user, + }) - return matchesSearch && matchesLocation && matchesStatus && matchesType - }), - [searchTerm, locationFilter, statusFilter, typeFilter], - ) + const locations = locationsData?.locations ?? [] + const categories = categoriesData?.equipment_categories ?? [] + const equipment = data?.equipment ?? [] return ( -
- {/* Header section with search and primary actions */} -
+
+

{t('equipment.inventory')} @@ -83,11 +112,10 @@ export const EquipmentList: FC = () => {

- - {/* Filtering interface */} - -
-
+ + +
+
{
- - {/* Detailed inventory table */} + + + {error && ( +

+ {error.message} +

+ )} @@ -161,69 +206,72 @@ export const EquipmentList: FC = () => { - {filteredEquipment.length > 0 - ? ( - filteredEquipment.map((item) => ( - navigate(`/equipment/${item.id}`)} - className='group cursor-pointer' - > - -
- - {item.name} - - - {item.qrCodeId} - -
-
- - - {CATEGORIES.find((c) => c.id === item.categoryId)?.name} - - - {LOCATIONS.find((l) => l.id === item.locationId)?.name} + {fetching ? ( + // Skeleton rows — replace with your Skeleton component if you have one + Array.from({ length: 6 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + +
- - - - - - - {new Date(item.installDate).toLocaleDateString( - 'en-GB', - { - day: '2-digit', - month: 'short', - year: 'numeric', - }, - )} - - - - - - - )) - ) - : ( - - - {t('common.no_results')} + ))} + + )) + ) : equipment.length > 0 ? ( + equipment.map(item => ( + navigate(`/equipment/${item.id}`)} + className="group cursor-pointer" + > + +
+ + {item.name} + + + {item.qr_code_id} + +
+
+ + {item.equipment_category.name} + + + {item.location?.name || '-'} + + + + + + {new Date(item.install_date).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + })} + + +
)} + )) + ) : ( + + + {t('common.no_results')} + + + )}
diff --git a/front/src/types/index.ts b/front/src/types/index.ts index c332daa..867cd3a 100644 --- a/front/src/types/index.ts +++ b/front/src/types/index.ts @@ -20,13 +20,13 @@ export interface Company { * System user with role-based permissions. */ export interface User { - id: string - companyId: string + id: number + companyId: number name: string email: string phone?: string role: Role - defaultLocationId?: string + defaultLocationId?: number avatar?: string notificationPrefs?: { push: boolean @@ -41,13 +41,13 @@ export type LocationType = 'restaurant' | 'apartment' | 'office' * Physical site or location where assets are situated. */ export interface Location { - id: string - companyId: string + id: number + companyId: number + managerId: number name: string type: LocationType address: string timezone: string - managerId: string image?: string } @@ -55,8 +55,8 @@ export interface Location { * Specific zone within a location. */ export interface Area { - id: string - locationId: string + id: number + locationId: number name: string floor?: string description?: string @@ -66,8 +66,8 @@ export interface Area { * Classification for equipment types. */ export interface EquipmentCategory { - id: string - companyId: string + id: number + companyId: number name: string icon: string defaultMaintenanceIntervalDays: number @@ -92,9 +92,9 @@ export type EquipmentStatus = */ export interface Equipment { id: string - locationId: string - areaId: string - categoryId: string + locationId: number + areaId: number + categoryId: number name: string serialNumber: string manufacturer: string diff --git a/hasura/metadata/databases/default/tables/public_industry_templates.yaml b/hasura/metadata/databases/default/tables/public_industry_templates.yaml index 01171e2..eaa682b 100644 --- a/hasura/metadata/databases/default/tables/public_industry_templates.yaml +++ b/hasura/metadata/databases/default/tables/public_industry_templates.yaml @@ -1,7 +1,6 @@ table: name: industry_templates schema: public - select_permissions: - role: ops_manager permission: diff --git a/hasura/metadata/databases/default/tables/public_schema_migrations.yaml b/hasura/metadata/databases/default/tables/public_schema_migrations.yaml new file mode 100644 index 0000000..a95c564 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_schema_migrations.yaml @@ -0,0 +1,3 @@ +table: + name: schema_migrations + schema: public From ad65a645efe4e3cc051d18e6fcf7cb0cee827b69 Mon Sep 17 00:00:00 2001 From: lee Date: Wed, 29 Apr 2026 22:31:45 +0100 Subject: [PATCH 07/11] chore(front): fix rebsae conflicts --- front/src/context/AuthContext.tsx | 6 +- front/src/lib/auth.ts | 4 +- front/src/lib/graphql-client.ts | 16 +- front/src/pages/Docs/ApiDocs.tsx | 255 ++++++++++---------- front/src/pages/Equipment/EquipmentList.tsx | 133 +++++----- front/src/pages/Login.tsx | 36 ++- 6 files changed, 215 insertions(+), 235 deletions(-) diff --git a/front/src/context/AuthContext.tsx b/front/src/context/AuthContext.tsx index 40a6f9e..a3a6511 100644 --- a/front/src/context/AuthContext.tsx +++ b/front/src/context/AuthContext.tsx @@ -1,7 +1,7 @@ import type { FC, ReactNode } from 'react' import type { User } from '../types/index.ts' import { createContext, useContext, useEffect, useState } from 'react' -import { authApi, tokenStore, isApiError } from '../lib/auth.ts' +import { authApi, isApiError, tokenStore } from '../lib/auth.ts' interface AuthContextType { user: User | null @@ -46,9 +46,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { setUser(user) } catch (err) { const message = isApiError(err) - ? err.status === 401 - ? 'Invalid email or password.' - : err.message + ? err.status === 401 ? 'Invalid email or password.' : err.message : 'Network error. Please try again.' setError(message) diff --git a/front/src/lib/auth.ts b/front/src/lib/auth.ts index adb10b1..5ac8ee5 100644 --- a/front/src/lib/auth.ts +++ b/front/src/lib/auth.ts @@ -21,9 +21,7 @@ const request = async (path: string, init: RequestInit = {}): Promise => { credentials: 'include', headers: { 'Content-Type': 'application/json', - ...(tokenStore.get() - ? { Authorization: `Bearer ${tokenStore.get()}` } - : {}), + ...(tokenStore.get() ? { Authorization: `Bearer ${tokenStore.get()}` } : {}), ...init.headers, }, }) diff --git a/front/src/lib/graphql-client.ts b/front/src/lib/graphql-client.ts index ec56fbd..30c62e7 100644 --- a/front/src/lib/graphql-client.ts +++ b/front/src/lib/graphql-client.ts @@ -1,7 +1,7 @@ -import { createClient, cacheExchange } from 'urql' -import { makeResult, makeErrorResult, type Exchange } from '@urql/core' +import { cacheExchange, createClient } from 'urql' +import { type Exchange, makeErrorResult, makeResult } from '@urql/core' import { print } from 'graphql' -import { pipe, mergeMap, fromPromise, fromValue } from 'wonka' +import { fromPromise, fromValue, mergeMap, pipe } from 'wonka' // token was injected on the main.ts by the auth.ts let _getToken: () => string | null = () => null @@ -10,10 +10,10 @@ export const setTokenGetter = (fn: () => string | null): void => { _getToken = fn } -const plainFetchExchange: Exchange = () => ops$ => +const plainFetchExchange: Exchange = () => (ops$) => pipe( ops$, - mergeMap(op => { + mergeMap((op) => { if (op.kind !== 'query' && op.kind !== 'mutation') { return fromValue({ operation: op, @@ -37,9 +37,9 @@ const plainFetchExchange: Exchange = () => ops$ => operationName: op.operationName, }), }) - .then(res => res.json()) - .then(result => makeResult(op, result)) - .catch(err => makeErrorResult(op, err)) + .then((res) => res.json()) + .then((result) => makeResult(op, result)) + .catch((err) => makeErrorResult(op, err)) return fromPromise(fetcher) }), diff --git a/front/src/pages/Docs/ApiDocs.tsx b/front/src/pages/Docs/ApiDocs.tsx index f07f4e2..28b1ccc 100644 --- a/front/src/pages/Docs/ApiDocs.tsx +++ b/front/src/pages/Docs/ApiDocs.tsx @@ -1,16 +1,16 @@ -import { useState, useEffect, type FC } from 'react' +import { type FC, useEffect, useState } from 'react' import { - Key, - Link as LinkIcon, - Layers, - ShieldAlert, + AlertCircle, + Check, ChevronDown, ChevronRight, Copy, - Check, + Key, + Layers, + Link as LinkIcon, Loader2, - AlertCircle, Lock, + ShieldAlert, Unlock, } from 'lucide-react' import { cn } from '../../lib/utils' @@ -110,15 +110,11 @@ function CopyButton({ text }: { text: string }) { } return ( ) } @@ -131,14 +127,14 @@ function CodeBlock({ language?: string }) { return ( -
-
- +
+
+ {language}
-
+      
         {children}
       
@@ -167,8 +163,7 @@ function EndpointRow({ if (resolved?.properties) { const ex: Record = {} for (const [k, v] of Object.entries(resolved.properties)) { - ex[k] = - v.example ?? + ex[k] = v.example ?? (v.type === 'string' ? '' : v.type === 'integer' ? 0 : null) } return JSON.stringify(ex, null, 2) @@ -179,37 +174,37 @@ function EndpointRow({ const requestExample = operation.requestBody ? (() => { - const content = Object.values(operation.requestBody.content)[0] - return content?.example - ? JSON.stringify(content.example, null, 2) - : resolveExample(content?.schema) - })() + const content = Object.values(operation.requestBody.content)[0] + return content?.example + ? JSON.stringify(content.example, null, 2) + : resolveExample(content?.schema) + })() : null return ( -
+
{/* Row header — always visible */} {!collapsed && ( -
+
{endpoints.map(({ method, path, operation }) => ( { useEffect(() => { fetch('/api/docs') - .then(res => { + .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }) - .then(data => { + .then((data) => { setSpec(data) setLoading(false) }) - .catch(err => { + .catch((err) => { setError(err.message) setLoading(false) }) @@ -447,9 +444,9 @@ export const ApiDocs: FC = () => { if (loading) { return ( -
- - +
+ + Loading spec...
@@ -458,11 +455,11 @@ export const ApiDocs: FC = () => { if (error || !spec) { return ( -
+
- + Failed to load API spec — is the API running?{' '} - {error} + {error}
) @@ -486,39 +483,39 @@ export const ApiDocs: FC = () => { } return ( -
+
{/* Header */} -
-
+
+
-

+

{spec.info.title}

-

+

{spec.info.description}

- + v{spec.info.version}
{/* Servers */} -
- {spec.servers.map(server => ( +
+ {spec.servers.map((server) => (
-

+

{server.description}

- + {server.url}
@@ -529,29 +526,29 @@ export const ApiDocs: FC = () => { {/* Auth schemes */} {Object.keys(securitySchemes).length > 0 && ( -
-

+
+

Authentication

-
+
{Object.entries(securitySchemes).map(([name, scheme]) => ( - - -
- - + + +
+ + {name} - + {scheme.scheme ?? scheme.type} {scheme.bearerFormat && ( - + {scheme.bearerFormat} )}
-

+

{scheme.description}

@@ -562,13 +559,13 @@ export const ApiDocs: FC = () => { )} {/* Endpoints grouped by tag */} -
-

+
+

Endpoints

-
- {spec.tags.map(tag => { +
+ {spec.tags.map((tag) => { const endpoints = endpointsByTag[tag.name] ?? [] if (endpoints.length === 0) return null return ( diff --git a/front/src/pages/Equipment/EquipmentList.tsx b/front/src/pages/Equipment/EquipmentList.tsx index f22e45b..6aedf1f 100644 --- a/front/src/pages/Equipment/EquipmentList.tsx +++ b/front/src/pages/Equipment/EquipmentList.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, type FC } from 'react' +import { type FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Card, CardContent, CardHeader } from '../../components/Card' import { @@ -15,10 +15,7 @@ import { Input, Select } from '../../components/Input.tsx' import { ChevronRight, Plus, QrCode } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { useAuth } from '../../context/AuthContext' -import { - useGetEquipmentList, - useGetEquipmentCategories, -} from '../../lib/equipment' +import { useGetEquipmentCategories, useGetEquipmentList } from '../../lib/equipment' import { useGetLocations } from '../../lib/locations' // debounce on search @@ -86,8 +83,8 @@ export const EquipmentList: FC = () => { const equipment = data?.equipment ?? [] return ( -
-
+
+

{t('equipment.inventory')} @@ -112,10 +109,10 @@ export const EquipmentList: FC = () => {

- - -
-
+ + +
+
{ + onChange={(e) => setCategoryFilter( e.target.value === 'all' ? null : e.target.value, - ) - } - className="w-full sm:w-44" + )} + className='w-full sm:w-44' > - - {categories.map(c => ( + + {categories.map((c) => ( @@ -162,25 +157,24 @@ export const EquipmentList: FC = () => {
- + {error && ( -

+

{error.message}

)} @@ -206,72 +200,73 @@ export const EquipmentList: FC = () => { - {fetching ? ( - // Skeleton rows — replace with your Skeleton component if you have one - Array.from({ length: 6 }).map((_, i) => ( - - {Array.from({ length: 6 }).map((_, j) => ( - -
- - ))} - - )) - ) : equipment.length > 0 ? ( - equipment.map(item => ( + {fetching + ? ( + // Skeleton rows — replace with your Skeleton component if you have one + Array.from({ length: 6 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + +
+ + ))} + + )) + ) + : equipment.length > 0 + ? equipment.map((item) => ( navigate(`/equipment/${item.id}`)} - className="group cursor-pointer" + className='group cursor-pointer' > -
- +
+ {item.name} - + {item.qr_code_id}
- + {item.equipment_category.name} - + {item.location?.name || '-'} - + {new Date(item.install_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', })} - + - )} )) - ) : ( - - - {t('common.no_results')} - - - )} + : ( + + + {t('common.no_results')} + + + )} diff --git a/front/src/pages/Login.tsx b/front/src/pages/Login.tsx index 30bbe4e..0fbdbaa 100644 --- a/front/src/pages/Login.tsx +++ b/front/src/pages/Login.tsx @@ -1,15 +1,9 @@ -import { useState, type FC, type FormEvent } from 'react' +import { type FC, type FormEvent, useState } from 'react' import { useAuth } from '../context/AuthContext.tsx' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '../components/Card.tsx' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/Card.tsx' import { Button } from '../components/Button.tsx' import { motion } from 'framer-motion' -import { QrCode, Sun, Moon, Globe, Loader2 } from 'lucide-react' +import { Globe, Loader2, Moon, QrCode, Sun } from 'lucide-react' import { Input } from '../components/Input.tsx' import { useTranslation } from 'react-i18next' import { useTheme } from '../context/ThemeContext.tsx' @@ -87,9 +81,9 @@ export const Login: FC = () => {
- - - + + + {t('login.title')} @@ -103,8 +97,8 @@ export const Login: FC = () => { type='email' placeholder='email@example.com' value={email} - onChange={e => setEmail(e.target.value)} - autoComplete="email" + onChange={(e) => setEmail(e.target.value)} + autoComplete='email' required />
@@ -113,8 +107,8 @@ export const Login: FC = () => { type='password' placeholder='••••••••' value={password} - onChange={e => setPassword(e.target.value)} - autoComplete="current-password" + onChange={(e) => setPassword(e.target.value)} + autoComplete='current-password' required />
@@ -128,19 +122,17 @@ export const Login: FC = () => {
{error && ( -

+

{error}

)} From 065a99c93addd2c99ae4dd450920d48bd8e2bbd2 Mon Sep 17 00:00:00 2001 From: lee Date: Thu, 30 Apr 2026 22:35:16 +0100 Subject: [PATCH 08/11] chore: rebase conficts solving --- .../databases/default/tables/public_industry_templates.yaml | 1 + .../databases/default/tables/public_schema_migrations.yaml | 3 --- hasura/migrations/003_locations.up.sql | 0 hasura/migrations/016_industry_templates.up.sql | 0 4 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 hasura/metadata/databases/default/tables/public_schema_migrations.yaml delete mode 100644 hasura/migrations/003_locations.up.sql delete mode 100644 hasura/migrations/016_industry_templates.up.sql diff --git a/hasura/metadata/databases/default/tables/public_industry_templates.yaml b/hasura/metadata/databases/default/tables/public_industry_templates.yaml index eaa682b..01171e2 100644 --- a/hasura/metadata/databases/default/tables/public_industry_templates.yaml +++ b/hasura/metadata/databases/default/tables/public_industry_templates.yaml @@ -1,6 +1,7 @@ table: name: industry_templates schema: public + select_permissions: - role: ops_manager permission: diff --git a/hasura/metadata/databases/default/tables/public_schema_migrations.yaml b/hasura/metadata/databases/default/tables/public_schema_migrations.yaml deleted file mode 100644 index a95c564..0000000 --- a/hasura/metadata/databases/default/tables/public_schema_migrations.yaml +++ /dev/null @@ -1,3 +0,0 @@ -table: - name: schema_migrations - schema: public diff --git a/hasura/migrations/003_locations.up.sql b/hasura/migrations/003_locations.up.sql deleted file mode 100644 index e69de29..0000000 diff --git a/hasura/migrations/016_industry_templates.up.sql b/hasura/migrations/016_industry_templates.up.sql deleted file mode 100644 index e69de29..0000000 From 22c50e7f67405da772e8f169ec62fb0a31c96509 Mon Sep 17 00:00:00 2001 From: lee Date: Fri, 1 May 2026 17:56:41 +0100 Subject: [PATCH 09/11] feat(front|api): add ui and refactor api for magic link - login page would have an option to login using magic link - auth verifier for when users click on the magic link from the email --- api/cmd/server/main.go | 2 +- api/internal/auth/handler.go | 39 +++--- api/internal/auth/service.go | 6 +- api/pkg/config/config.go | 5 +- docker-compose.dev.yml | 1 + front/src/App.tsx | 3 + front/src/context/ToastContext.tsx | 4 +- front/src/lib/equipment.ts | 2 - front/src/locales/en-GB.json | 9 +- front/src/locales/pt-PT.json | 9 +- front/src/pages/AuthVerify.tsx | 126 +++++++++++++++++ front/src/pages/Docs/ApiDocs.tsx | 6 +- front/src/pages/Docs/index.tsx | 13 +- front/src/pages/Login.tsx | 214 ++++++++++++++++++++--------- front/src/pages/QRScanner.tsx | 4 +- 15 files changed, 346 insertions(+), 97 deletions(-) create mode 100644 front/src/pages/AuthVerify.tsx diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index b2b6696..2c2895a 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -108,7 +108,7 @@ func main() { }) // auth routes - authHandler := auth.NewHandler(authService, authMiddleware, !cfg.IsDev()) + authHandler := auth.NewHandler(authService, authMiddleware, cfg, !cfg.IsDev()) authHandler.RegisterRoutes(mux) // docs routes diff --git a/api/internal/auth/handler.go b/api/internal/auth/handler.go index db5112c..9756273 100644 --- a/api/internal/auth/handler.go +++ b/api/internal/auth/handler.go @@ -1,8 +1,10 @@ package auth import ( + "api/pkg/config" "encoding/json" "errors" + "fmt" "net/http" "strconv" "time" @@ -12,11 +14,12 @@ import ( type Handler struct { svc *Service middleware *Middleware + cfg *config.Config isProd bool } -func NewHandler(svc *Service, middleware *Middleware, isProd bool) *Handler { - return &Handler{svc: svc, middleware: middleware, isProd: isProd} +func NewHandler(svc *Service, middleware *Middleware, cfg *config.Config, isProd bool) *Handler { + return &Handler{svc: svc, middleware: middleware, cfg: cfg, isProd: isProd} } // all auth endpoints @@ -156,6 +159,11 @@ type magicLinkRequest struct { Email string `json:"email"` } +// TODO: send email with the magic link URL: +// +// https://DOMAIN/api/auth/magic-link?token= +// +// The email service will be wired in the notify package func (h *Handler) sendMagicLink(w http.ResponseWriter, r *http.Request) { var req magicLinkRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -167,21 +175,21 @@ func (h *Handler) sendMagicLink(w http.ResponseWriter, r *http.Request) { return } - // Service returns nil token when email doesn't exist — prevents enumeration. + // Service returns nil token when email doesn't exist — prevents enumeration // We respond with 200 in both cases so attackers can't probe for emails. - _, err := h.svc.SendMagicLink(r.Context(), SendMagicLinkInput{Email: req.Email}) + magicLinkToken, err := h.svc.SendMagicLink(r.Context(), SendMagicLinkInput{Email: req.Email}) if err != nil { - // Log internally but return 200 to the client. // TODO: inject a logger and log err here + fmt.Println(err) + + // log internally but return 200 to the client writeJSON(w, http.StatusOK, map[string]string{ "message": "if that email exists, a login link has been sent", }) return } - // TODO: send email via Postmark with the magic link URL: - // https://app.operafix.com/auth/verify?token= - // The email service will be wired in the notify package. + fmt.Println("FOR TESTING - magic link token:", h.cfg.FrontendURL+"/api/auth/magic-link?token="+magicLinkToken) writeJSON(w, http.StatusOK, map[string]string{ "message": "if that email exists, a login link has been sent", @@ -189,31 +197,28 @@ func (h *Handler) sendMagicLink(w http.ResponseWriter, r *http.Request) { } // GET /auth/magic-link?token= +// validate the token and redirect the user to the front to be validated func (h *Handler) verifyMagicLink(w http.ResponseWriter, r *http.Request) { rawToken := r.URL.Query().Get("token") if rawToken == "" { - writeError(w, http.StatusBadRequest, "missing token") + http.Redirect(w, r, h.cfg.FrontendURL+"/auth/error?reason=missing_token", http.StatusSeeOther) return } result, err := h.svc.VerifyMagicLink(r.Context(), rawToken) if err != nil { if errors.Is(err, ErrUnauthorized) { - writeError(w, http.StatusUnauthorized, "invalid or expired link") + http.Redirect(w, r, h.cfg.FrontendURL+"/auth/error?reason=invalid_token", http.StatusSeeOther) return } - writeError(w, http.StatusInternalServerError, "verification failed") + http.Redirect(w, r, h.cfg.FrontendURL+"/auth/error?reason=server_error", http.StatusSeeOther) return } h.setRefreshCookie(w, result.RefreshToken, result.RefreshExpiresAt) - writeJSON(w, http.StatusOK, loginResponse{ - AccessToken: result.AccessToken, - TokenType: "Bearer", - ExpiresIn: int(15 * time.Minute / time.Second), - User: toAuthResponse(result.User), - }) + // the token is already set on the cookie + http.Redirect(w, r, h.cfg.FrontendURL+"/auth/verify", http.StatusSeeOther) } // Cookie helpers diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go index 86b2ed7..f2f9f0b 100644 --- a/api/internal/auth/service.go +++ b/api/internal/auth/service.go @@ -161,10 +161,10 @@ type SendMagicLinkInput struct { func (s *Service) SendMagicLink(ctx context.Context, input SendMagicLinkInput) (token string, err error) { user, err := s.repo.getUserByEmail(ctx, input.Email) if err != nil { + // do not reveal whether the email is registered if errors.Is(err, ErrNotFound) { - // Do not reveal whether the email is registered. - // Return a dummy token to maintain consistent response time. - return "", nil + // return empty token + return "", fmt.Errorf("magic link user: %w", err) } return "", fmt.Errorf("send magic link: %w", err) } diff --git a/api/pkg/config/config.go b/api/pkg/config/config.go index 47f3618..f59135d 100644 --- a/api/pkg/config/config.go +++ b/api/pkg/config/config.go @@ -26,6 +26,8 @@ type Config struct { JWTRefreshExpiry time.Duration CORSAllowedOrigin string + + FrontendURL string // // Email (Postmark) // PostmarkAPIKey string // PostmarkFrom string @@ -46,7 +48,8 @@ func Load() *Config { JWTSecret: require("JWT_SECRET"), JWTAccessExpiry: parseDuration("JWT_ACCESS_EXPIRY", 15*time.Minute), JWTRefreshExpiry: parseDuration("JWT_REFRESH_EXPIRY", 7*24*time.Hour), - CORSAllowedOrigin: getEnv("CORS_ALLOWED_ORIGIN", "http://localhost:8081"), + CORSAllowedOrigin: getEnv("CORS_ALLOWED_ORIGIN", "http://localhost:5173"), + FrontendURL: getEnv("FRONTEND_URL", "http://localhost:5173"), // // External services — optional locally, required in production. // // The service checks these at call time and skips gracefully if empty. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6fb608f..2ccb7b0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -113,6 +113,7 @@ services: JWT_REFRESH_EXPIRY: ${JWT_REFRESH_EXPIRY:-168h} APP_ENV: development CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:5173} + FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173} LOG_LEVEL: debug PORT: 8080 # # External services — leave blank locally unless actively testing diff --git a/front/src/App.tsx b/front/src/App.tsx index 48982d4..61bb6c0 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -16,6 +16,7 @@ import { ReportsList } from './pages/Reports/ReportsList.tsx' import { QRScanner } from './pages/QRScanner.tsx' import { TechnicianAssignment } from './pages/Technicians/TechnicianAssignment.tsx' import { Analytics } from './pages/Analytics.tsx' +import { AuthVerify } from './pages/AuthVerify.tsx' import { PreventiveList } from './pages/Preventive/PreventiveList.tsx' import { PWAInstallPrompt } from './components/PWAInstallPrompt.tsx' import { Docs } from './pages/Docs/index.tsx' @@ -69,6 +70,8 @@ function App() { {/* Public Routes */} } /> + } /> + } /> {/* Protected Routes wrapped with Layout */} = ({ children }) => { )} > {t.type === 'success' && } - {t.type === 'error' && } + {t.type === 'error' && } {t.type === 'info' && } {t.message} diff --git a/front/src/lib/equipment.ts b/front/src/lib/equipment.ts index 03fad65..5a0d472 100644 --- a/front/src/lib/equipment.ts +++ b/front/src/lib/equipment.ts @@ -4,7 +4,6 @@ export interface EquipmentRow { id: string name: string serial_number: string - qr_code_id: string status: 'active' | 'under_repair' | 'decommissioned' install_date: string location: { id: number; name: string } @@ -38,7 +37,6 @@ const EQUIPMENT_LIST_QUERY = gql` id name serial_number - qr_code_id status install_date location { id name } diff --git a/front/src/locales/en-GB.json b/front/src/locales/en-GB.json index 03ad721..07680a6 100644 --- a/front/src/locales/en-GB.json +++ b/front/src/locales/en-GB.json @@ -53,7 +53,14 @@ "demo_identity": "Demo Identity", "select_identity": "Select identity...", "restricted_access": "Restricted Access Terminal.", - "request_credentials": "Request Credentials" + "request_credentials": "Request Credentials", + "mode_password": "Password", + "mode_magic_link": "Magic Link", + "magic_link_description": "Enter your email and we'll send you a login link. No password needed.", + "magic_link_send": "Send Login Link", + "magic_link_sent": "Check your inbox", + "magic_link_check_inbox": "We sent a login link to", + "magic_link_resend": "Send to a different email" }, "branding": { "tagline": "OperaFix: Operations without failures", diff --git a/front/src/locales/pt-PT.json b/front/src/locales/pt-PT.json index bd79f09..f932ce3 100644 --- a/front/src/locales/pt-PT.json +++ b/front/src/locales/pt-PT.json @@ -53,7 +53,14 @@ "demo_identity": "Identidade de Demonstração (Demo)", "select_identity": "Selecione uma identidade...", "restricted_access": "Terminal de Acesso Restrito.", - "request_credentials": "Solicitar Credenciais" + "request_credentials": "Solicitar Credenciais", + "mode_password": "Password", + "mode_magic_link": "Link Magico", + "magic_link_description": "Digite o seu email e iremos mandar um email com um login link.", + "magic_link_send": "Enviar Login Link", + "magic_link_sent": "Verifica a tua email", + "magic_link_check_inbox": "Mandamos um login link para", + "magic_link_resend": "Mandado para outro email" }, "branding": { "tagline": "OperaFix: Operações sem falhas", diff --git a/front/src/pages/AuthVerify.tsx b/front/src/pages/AuthVerify.tsx new file mode 100644 index 0000000..d956379 --- /dev/null +++ b/front/src/pages/AuthVerify.tsx @@ -0,0 +1,126 @@ +import { type FC, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { CheckCircle2, Loader2, QrCode, XCircle } from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/Card.tsx' +import { Button } from '../components/Button.tsx' +import { useAuth } from '../context/AuthContext.tsx' + +type State = 'loading' | 'success' | 'error' + +export const AuthVerify: FC = () => { + const navigate = useNavigate() + const { user, isLoading } = useAuth() + const [state, setState] = useState('loading') + + const params = new URLSearchParams(window.location.search) + const hasError = params.get('error') + + useEffect(() => { + if (hasError) { + setState('error') + return + } + + // AuthContext useEffect already called refresh() on mount + if (!isLoading) { + if (user) { + setState('success') + setTimeout(() => navigate('/'), 1500) + } else { + setState('error') + } + } + }, [isLoading, user]) + + return ( +
+
+
+
+ + + {/* Same branding header as Login */} +
+
+ +
+
+

+ OperaFix +

+
+
+ + + + + {state === 'loading' && 'Verifying Link'} + {state === 'success' && 'Access Granted'} + {state === 'error' && 'Link Invalid'} + + + {state === 'loading' && 'Please wait...'} + {state === 'success' && 'Redirecting to dashboard'} + {state === 'error' && 'This link has expired or already been used'} + + + + + {state === 'loading' && ( + + +

+ Authenticating session +

+
+ )} + + {state === 'success' && ( + + +

+ Login successful +

+
+ )} + + {state === 'error' && ( + + +

+ Request a new link from the login page +

+ +
+ )} +
+
+
+
+ ) +} diff --git a/front/src/pages/Docs/ApiDocs.tsx b/front/src/pages/Docs/ApiDocs.tsx index 28b1ccc..8b0340f 100644 --- a/front/src/pages/Docs/ApiDocs.tsx +++ b/front/src/pages/Docs/ApiDocs.tsx @@ -1,9 +1,9 @@ import { type FC, useEffect, useState } from 'react' import { - AlertCircle, Check, ChevronDown, ChevronRight, + CircleAlert, Copy, Key, Layers, @@ -341,7 +341,7 @@ function EndpointRow({ {content.example ? (
-                                    {JSON.stringify(content.example, null, 2)}
+                                      {JSON.stringify(content.example, null, 2)}
                                     
) : content.schema?.$ref @@ -456,7 +456,7 @@ export const ApiDocs: FC = () => { if (error || !spec) { return (
- + Failed to load API spec — is the API running?{' '} {error} diff --git a/front/src/pages/Docs/index.tsx b/front/src/pages/Docs/index.tsx index ba88e5b..cb49421 100644 --- a/front/src/pages/Docs/index.tsx +++ b/front/src/pages/Docs/index.tsx @@ -1,6 +1,15 @@ import type React from 'react' import { useState } from 'react' -import { Box, Database, GitMerge, ListTodo, Map as MapIcon, Palette, Terminal } from 'lucide-react' +import { + Box, + Database, + GitMerge, + Landmark, + ListTodo, + Map as MapIcon, + Palette, + Terminal, +} from 'lucide-react' import { cn } from '../../lib/utils.ts' import { ThemingDocs } from './ThemingDocs.tsx' import { WorkflowDocs } from './WorkflowDocs.tsx' @@ -19,7 +28,7 @@ export const Docs: React.FC = () => { { id: 'schema', label: 'Data Schema (Current)', icon: Database }, { id: 'workflow', label: 'System Workflow', icon: GitMerge }, { id: 'user_journey', label: 'User Journeys', icon: MapIcon }, - { id: 'api', label: 'Api Docs', icon: AlertCircle }, + { id: 'api', label: 'Api Docs', icon: Landmark }, { id: 'next_phase', label: 'Next Phase & To-Do', icon: ListTodo }, ] diff --git a/front/src/pages/Login.tsx b/front/src/pages/Login.tsx index 0fbdbaa..2d370c0 100644 --- a/front/src/pages/Login.tsx +++ b/front/src/pages/Login.tsx @@ -3,24 +3,29 @@ import { useAuth } from '../context/AuthContext.tsx' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/Card.tsx' import { Button } from '../components/Button.tsx' import { motion } from 'framer-motion' -import { Globe, Loader2, Moon, QrCode, Sun } from 'lucide-react' +import { CheckCircle2, Globe, Loader2, Mail, Moon, QrCode, Sun } from 'lucide-react' import { Input } from '../components/Input.tsx' import { useTranslation } from 'react-i18next' import { useTheme } from '../context/ThemeContext.tsx' +import { authApi } from '../lib/auth.ts' + +type LoginMode = 'password' | 'magic-link' export const Login: FC = () => { const { t, i18n } = useTranslation() const { login, error } = useAuth() const { mode, toggleMode } = useTheme() + const [loginMode, setLoginMode] = useState('password') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const [magicLinkSent, setMagicLinkSent] = useState(false) + const [magicLinkError, setMagicLinkError] = useState(null) const handleLogin = async (e: FormEvent) => { e.preventDefault() if (!email || !password) return - setIsSubmitting(true) try { await login(email, password) @@ -30,9 +35,23 @@ export const Login: FC = () => { } } + const handleMagicLink = async (e: FormEvent) => { + e.preventDefault() + if (!email) return + setIsSubmitting(true) + setMagicLinkError(null) + try { + await authApi.magicLink(email) + setMagicLinkSent(true) + } catch { + setMagicLinkError('Something went wrong. Please try again.') + } finally { + setIsSubmitting(false) + } + } + return (
- {/* Configuration controls in the top corner */}
-
- {/* Decorative background gradients */}
@@ -66,15 +79,12 @@ export const Login: FC = () => { transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }} className='max-w-md w-full relative z-10' > - {/* Branding header */}
-

- OperaFix -

+

OperaFix

{t('branding.tagline')}
@@ -83,69 +93,149 @@ export const Login: FC = () => { - - {t('login.title')} - + {t('login.title')} {t('login.subtitle')} - -
- setEmail(e.target.value)} - autoComplete='email' - required - /> -
+ + + {/* Mode switcher */} +
+ {(['password', 'magic-link'] as LoginMode[]).map((m) => ( + + ))} +
+ + {/* Password form */} + {loginMode === 'password' && ( + setPassword(e.target.value)} - autoComplete='current-password' + label={t('common.email')} + type='email' + placeholder='email@example.com' + value={email} + onChange={(e) => setEmail(e.target.value)} + autoComplete='email' required /> -
- +
+ setPassword(e.target.value)} + autoComplete='current-password' + required + /> +
+ +
-
- - {error && ( -

- {error} -

- )} - - + + )} + + {/* Magic link form */} + {loginMode === 'magic-link' && ( + - {isSubmitting ? : ( - t('common.login') + {magicLinkSent ? ( + + +
+

+ {t('login.magic_link_sent')} +

+

+ {t('login.magic_link_check_inbox')} {email} +

+
+ +
+ ) : ( +
+

+ {t('login.magic_link_description')} +

+ setEmail(e.target.value)} + autoComplete='email' + required + /> + + {magicLinkError && ( +

+ {magicLinkError} +

+ )} + + +
)} - - +
+ )}

{t('login.restricted_access')}{' '} -

diff --git a/front/src/pages/QRScanner.tsx b/front/src/pages/QRScanner.tsx index 11ba408..5f3979f 100644 --- a/front/src/pages/QRScanner.tsx +++ b/front/src/pages/QRScanner.tsx @@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { AnimatePresence, motion } from 'framer-motion' import { - AlertCircle, Calendar, CheckCircle2, + CircleAlert, FilePlus, Info, MapPin, @@ -202,7 +202,7 @@ export const QRScanner: FC = () => {
- +

From 7b60c7c1c99af570a8d655f390628402dbd21a2a Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 3 May 2026 22:51:17 +0100 Subject: [PATCH 10/11] feat(front|api): create a route for user management - managing users is only possible with admin role or location manager - location manager can only manage add users from is own location - admins are the only ones that can delete/deactivate the user (is_active to false) - add new field on user table 'is_active' to allow soft delete - fix sequential id increment when we seed the db --- api/cmd/server/main.go | 14 +- api/internal/auth/middleware.go | 42 +- api/internal/docs/handler.go | 212 +++++++- api/internal/user/handler.go | 257 +++++++++ api/internal/user/repository.go | 285 ++++++++++ api/internal/user/service.go | 136 +++++ front/src/context/AuthContext.tsx | 2 +- front/src/lib/{ => api}/auth.ts | 2 +- front/src/lib/api/user.ts | 65 +++ front/src/main.tsx | 2 +- front/src/pages/Login.tsx | 131 +++-- front/src/pages/Settings.tsx | 512 +++++++++--------- .../default/1000000000007_users/up.sql | 2 + scripts/apply-mock.sh | 2 +- scripts/{mock-db.sql => seed.sql} | 10 + 15 files changed, 1338 insertions(+), 336 deletions(-) create mode 100644 api/internal/user/handler.go create mode 100644 api/internal/user/repository.go create mode 100644 api/internal/user/service.go rename front/src/lib/{ => api}/auth.ts (97%) create mode 100644 front/src/lib/api/user.ts rename scripts/{mock-db.sql => seed.sql} (96%) diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index 2c2895a..f6bcad1 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "api/internal/auth" "api/internal/docs" + "api/internal/user" "api/pkg/config" "api/pkg/database" ) @@ -84,13 +85,17 @@ func main() { slog.Info("database connected") // services + // auth service authService := auth.NewService( db, cfg.JWTSecret, cfg.JWTAccessExpiry, cfg.JWTRefreshExpiry, ) + // user service + usersService := user.NewService(db, authService) + // middleware, security layer for routes authMiddleware := auth.NewMiddleware( cfg.JWTSecret, cfg.JWTAccessExpiry, @@ -115,10 +120,9 @@ func main() { docsHandler := docs.NewHandler(!cfg.IsDev()) docsHandler.RegisterRoutes(mux) - // TODO: register additional route groups here as they are built: - // equipmentHandler.RegisterRoutes(mux) - // issueHandler.RegisterRoutes(mux) - // ... + // user routes + usersHandler := user.NewHandler(usersService, authMiddleware) + usersHandler.RegisterRoutes(mux) // http server server := &http.Server{ @@ -136,7 +140,7 @@ func main() { } // shitdown gracefully - // Listen for SIGINT (Ctrl+C) and SIGTERM (docker compose stop / Railway deploy) + // Listen for SIGINT (Ctrl+C) and SIGTERM // On signal: stop accepting new connections, wait up to 30s for in-flight // requests to finish, then exit cleanly quit := make(chan os.Signal, 1) diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go index 602b4f8..88d36d9 100644 --- a/api/internal/auth/middleware.go +++ b/api/internal/auth/middleware.go @@ -11,9 +11,10 @@ import ( type contextKey string const ( - contextKeyUserID contextKey = "user_id" - contextKeyCompanyID contextKey = "company_id" - contextKeyRole contextKey = "role" + contextKeyUserID contextKey = "user_id" + contextKeyCompanyID contextKey = "company_id" + contextKeyRole contextKey = "role" + contextKeyLocationID contextKey = "location_id" ) // validates the JWT from the Authorization header and injects @@ -34,6 +35,35 @@ func NewMiddleware(jwtSecret string, accessExpiry, refreshExpiry time.Duration) } } +func LocationIDFromContext(ctx context.Context) *int64 { + id, ok := ctx.Value(contextKeyLocationID).(*int64) + if !ok { + return nil + } + return id +} + +// parses "{1,2,3}" -> pointer to 1 +// returns nil for admin/ops_manager who have no location scope +func parseFirstLocationID(pgArray string) *int64 { + if pgArray == "" || pgArray == "{}" { + return nil + } + // strip braces -> "1,2,3" + trimmed := strings.Trim(pgArray, "{}") + if trimmed == "" { + return nil + } + // take first id only + // phase 1 is single location + first := strings.SplitN(trimmed, ",", 2)[0] + id, err := strconv.ParseInt(strings.TrimSpace(first), 10, 64) + if err != nil { + return nil + } + return &id +} + // Require is an http.Handler wrapper that enforces authentication // Handlers wrapped with Require can safely call UserIDFromContext // the user is guaranteed to be authenticated at that point @@ -63,11 +93,13 @@ func (m *Middleware) Require(next http.Handler) http.Handler { return } - // Inject identity into context for downstream handlers. + locationID := parseFirstLocationID(claims.HasuraClaims.LocationIDs) + // inject identity into context for downstream handlers ctx := r.Context() ctx = context.WithValue(ctx, contextKeyUserID, userID) ctx = context.WithValue(ctx, contextKeyCompanyID, companyID) ctx = context.WithValue(ctx, contextKeyRole, Role(claims.HasuraClaims.DefaultRole)) + ctx = context.WithValue(ctx, contextKeyLocationID, locationID) next.ServeHTTP(w, r.WithContext(ctx)) }) @@ -107,7 +139,7 @@ func UserIDFromContext(ctx context.Context) int64 { return id } -// returns the authenticated user's company UUID from context. +// returns the authenticated user's company id from context. func CompanyIDFromContext(ctx context.Context) int64 { id, ok := ctx.Value(contextKeyCompanyID).(int64) if !ok { diff --git a/api/internal/docs/handler.go b/api/internal/docs/handler.go index abd163e..1c26ded 100644 --- a/api/internal/docs/handler.go +++ b/api/internal/docs/handler.go @@ -18,7 +18,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /docs", h.spec) } -// spec serves the raw OpenAPI JSON for external tools (Postman, Insomnia, etc.) func (h *Handler) spec(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -49,6 +48,7 @@ func openAPISpec() map[string]any { "tags": []map[string]any{ {"name": "health", "description": "Service health"}, {"name": "auth", "description": "Authentication — login, logout, token refresh, magic-link"}, + {"name": "users", "description": "User management — invite, list, update, deactivate"}, }, "paths": map[string]any{ "/health": map[string]any{ @@ -69,6 +69,8 @@ func openAPISpec() map[string]any { }, }, }, + + // auth "/auth/login": map[string]any{ "post": map[string]any{ "tags": []string{"auth"}, @@ -230,29 +232,175 @@ func openAPISpec() map[string]any { "get": map[string]any{ "tags": []string{"auth"}, "summary": "Verify a magic login link", - "description": "Validates the token from a magic-link email and issues a full session. Tokens expire after 15 minutes and can only be used once.", + "description": "Validates the token, sets the refresh cookie, and redirects to the frontend. Tokens expire after 15 minutes and can only be used once.", "operationId": "verifyMagicLink", "parameters": []map[string]any{ { "in": "query", "name": "token", "required": true, - "description": "The raw token from the magic-link URL", + "description": "The raw token from the magic-link email", "schema": map[string]any{"type": "string"}, }, }, + "responses": map[string]any{ + "303": map[string]any{ + "description": "Redirects to frontend /auth/verify (success) or /auth/verify?error=... (failure)", + "headers": map[string]any{ + "Location": map[string]any{"schema": map[string]any{"type": "string"}}, + "Set-Cookie": refreshCookieHeader()["Set-Cookie"], + }, + }, + "400": errorResponse("Missing token"), + }, + }, + }, + + // user + "/users": map[string]any{ + "post": map[string]any{ + "tags": []string{"users"}, + "summary": "Invite a new user", + "description": "Creates a user record and sends a magic-link invite email. Admins can create any role. Location managers can only create employees and technicians at their own location.", + "operationId": "inviteUser", + "security": []map[string]any{{"bearerAuth": []string{}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("InviteUserRequest"), + "example": map[string]any{ + "name": "Maria Silva", + "email": "maria.silva@grupotasca.pt", + "role": "employee", + "default_location_id": 1, + }, + }, + }, + }, + "responses": map[string]any{ + "201": map[string]any{ + "description": "User created and invite sent", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("UserDetail"), + }, + }, + }, + "400": errorResponse("Name, email and role are required"), + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("Insufficient permissions"), + "409": errorResponse("Email already in use"), + "500": errorResponse("Failed to create user"), + }, + }, + "get": map[string]any{ + "tags": []string{"users"}, + "summary": "List users", + "description": "Returns all active users in the company. Location managers only see users at their own location.", + "operationId": "listUsers", + "security": []map[string]any{{"bearerAuth": []string{}}}, "responses": map[string]any{ "200": map[string]any{ - "description": "Magic link verified — session issued", - "headers": refreshCookieHeader(), + "description": "List of users", "content": map[string]any{ "application/json": map[string]any{ - "schema": ref("AuthResponse"), + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "users": map[string]any{ + "type": "array", + "items": ref("UserDetail"), + }, + }, + }, }, }, }, - "400": errorResponse("Missing token"), - "401": errorResponse("Invalid or expired link"), + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("Insufficient permissions"), + }, + }, + }, + "/users/{id}": map[string]any{ + "get": map[string]any{ + "tags": []string{"users"}, + "summary": "Get a user", + "description": "Returns a single user by ID. Scoped to the requester's company.", + "operationId": "getUser", + "security": []map[string]any{{"bearerAuth": []string{}}}, + "parameters": []map[string]any{userIDParam()}, + "responses": map[string]any{ + "200": map[string]any{ + "description": "User found", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("UserDetail"), + }, + }, + }, + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("Insufficient permissions"), + "404": errorResponse("User not found"), + }, + }, + "put": map[string]any{ + "tags": []string{"users"}, + "summary": "Update a user", + "description": "Updates a user's name, role, phone, or default location. Admin only. All fields are optional — only provided fields are updated.", + "operationId": "updateUser", + "security": []map[string]any{{"bearerAuth": []string{}}}, + "parameters": []map[string]any{userIDParam()}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("UpdateUserRequest"), + "example": map[string]any{ + "role": "location_manager", + }, + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "User updated", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("UserDetail"), + }, + }, + }, + "400": errorResponse("Invalid request body"), + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("Insufficient permissions"), + "404": errorResponse("User not found"), + }, + }, + "delete": map[string]any{ + "tags": []string{"users"}, + "summary": "Deactivate a user", + "description": "Soft-deletes a user — preserves all history. The user can no longer log in. Admin only.", + "operationId": "deactivateUser", + "security": []map[string]any{{"bearerAuth": []string{}}}, + "parameters": []map[string]any{userIDParam()}, + "responses": map[string]any{ + "200": map[string]any{ + "description": "User deactivated", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{"type": "string", "example": "user deactivated"}, + }, + }, + }, + }, + }, + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("Insufficient permissions"), + "404": errorResponse("User not found"), }, }, }, @@ -302,8 +450,8 @@ func openAPISpec() map[string]any { "type": "object", "required": []string{"id", "company_id", "email", "role"}, "properties": map[string]any{ - "id": map[string]any{"type": "string", "format": "uuid", "example": "018e2f3a-1b2c-7d4e-8f5a-9b0c1d2e3f4a"}, - "company_id": map[string]any{"type": "string", "format": "uuid", "example": "018e2f3a-0000-7d4e-8f5a-9b0c1d2e3f4a"}, + "id": map[string]any{"type": "integer", "example": 1}, + "company_id": map[string]any{"type": "integer", "example": 1}, "email": map[string]any{"type": "string", "format": "email", "example": "admin@test.com"}, "role": map[string]any{ "type": "string", @@ -312,6 +460,40 @@ func openAPISpec() map[string]any { }, }, }, + "UserDetail": map[string]any{ + "type": "object", + "required": []string{"id", "company_id", "name", "email", "role", "is_active"}, + "properties": map[string]any{ + "id": map[string]any{"type": "integer", "example": 1}, + "company_id": map[string]any{"type": "integer", "example": 1}, + "name": map[string]any{"type": "string", "example": "Maria Silva"}, + "email": map[string]any{"type": "string", "format": "email", "example": "maria.silva@grupotasca.pt"}, + "phone": map[string]any{"type": "string", "nullable": true, "example": "+351912000001"}, + "role": map[string]any{"type": "string", "enum": []string{"employee", "technician", "location_manager", "ops_manager", "admin"}, "example": "employee"}, + "default_location_id": map[string]any{"type": "integer", "nullable": true, "example": 1}, + "is_active": map[string]any{"type": "boolean", "example": true}, + }, + }, + "InviteUserRequest": map[string]any{ + "type": "object", + "required": []string{"name", "email", "role"}, + "properties": map[string]any{ + "name": map[string]any{"type": "string", "example": "Maria Silva"}, + "email": map[string]any{"type": "string", "format": "email", "example": "maria.silva@grupotasca.pt"}, + "phone": map[string]any{"type": "string", "nullable": true, "example": "+351912000001"}, + "role": map[string]any{"type": "string", "enum": []string{"employee", "technician", "location_manager", "ops_manager", "admin"}, "example": "employee"}, + "default_location_id": map[string]any{"type": "integer", "nullable": true, "example": 1}, + }, + }, + "UpdateUserRequest": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string", "example": "Maria Silva"}, + "phone": map[string]any{"type": "string", "nullable": true, "example": "+351912000001"}, + "role": map[string]any{"type": "string", "enum": []string{"employee", "technician", "location_manager", "ops_manager", "admin"}, "example": "location_manager"}, + "default_location_id": map[string]any{"type": "integer", "nullable": true, "example": 2}, + }, + }, "ErrorResponse": map[string]any{ "type": "object", "required": []string{"error"}, @@ -349,3 +531,13 @@ func refreshCookieHeader() map[string]any { }, } } + +func userIDParam() map[string]any { + return map[string]any{ + "in": "path", + "name": "id", + "required": true, + "description": "User ID", + "schema": map[string]any{"type": "integer"}, + } +} diff --git a/api/internal/user/handler.go b/api/internal/user/handler.go new file mode 100644 index 0000000..073b79f --- /dev/null +++ b/api/internal/user/handler.go @@ -0,0 +1,257 @@ +package user + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "api/internal/auth" +) + +type Handler struct { + svc *Service + auth *auth.Middleware +} + +type userDTO struct { + ID int64 `json:"id"` + CompanyID int64 `json:"company_id"` + Name string `json:"name"` + Email string `json:"email"` + Phone *string `json:"phone"` + Role string `json:"role"` + DefaultLocationID *int64 `json:"default_location_id"` + IsActive bool `json:"is_active"` +} + +func toUserDTO(u *User) userDTO { + return userDTO{ + ID: u.ID, + CompanyID: u.CompanyID, + Name: u.Name, + Email: u.Email, + Phone: u.Phone, + Role: u.Role, + DefaultLocationID: u.DefaultLocationID, + IsActive: u.IsActive, + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func parseID(s string) (int64, error) { + return strconv.ParseInt(s, 10, 64) +} + +// locationIDFromContext reads the user's default location from the JWT claims +// returns nil for roles that have no location scope (admin, ops_manager) +func locationIDFromContext(ctx context.Context) *int64 { + return auth.LocationIDFromContext(ctx) +} + +func NewHandler(svc *Service, authMiddleware *auth.Middleware) *Handler { + return &Handler{svc: svc, auth: authMiddleware} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.Handle("POST /users", + h.auth.Require( + h.auth.RequireRole(auth.RoleAdmin, auth.RoleLocationManager)( + http.HandlerFunc(h.invite), + ), + ), + ) + + mux.Handle("GET /users", + h.auth.Require( + h.auth.RequireRole(auth.RoleAdmin, auth.RoleOpsManager, auth.RoleLocationManager)( + http.HandlerFunc(h.list), + ), + ), + ) + + mux.Handle("GET /users/{id}", + h.auth.Require( + h.auth.RequireRole(auth.RoleAdmin, auth.RoleOpsManager)( + http.HandlerFunc(h.get), + ), + ), + ) + + mux.Handle("PUT /users/{id}", + h.auth.Require( + h.auth.RequireRole(auth.RoleAdmin)( + http.HandlerFunc(h.update), + ), + ), + ) + + mux.Handle("DELETE /users/{id}", + h.auth.Require( + h.auth.RequireRole(auth.RoleAdmin)( + http.HandlerFunc(h.deactivate), + ), + ), + ) +} + +// POST /users, create the user +type inviteRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Phone *string `json:"phone"` + Role string `json:"role"` + DefaultLocationID *int64 `json:"default_location_id"` +} + +func (h *Handler) invite(w http.ResponseWriter, r *http.Request) { + var req inviteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" || req.Email == "" || req.Role == "" { + writeError(w, http.StatusBadRequest, "name, email and role are required") + return + } + + companyID := auth.CompanyIDFromContext(r.Context()) + requesterRole := auth.RoleFromContext(r.Context()) + requesterLocationID := locationIDFromContext(r.Context()) + + result, err := h.svc.Invite(r.Context(), InviteInput{ + CompanyID: companyID, + InviterRole: string(requesterRole), + InviterLocationID: requesterLocationID, + Name: req.Name, + Email: req.Email, + Phone: req.Phone, + Role: req.Role, + DefaultLocationID: req.DefaultLocationID, + }) + + if err != nil { + switch { + case errors.Is(err, ErrEmailTaken): + writeError(w, http.StatusConflict, "email already in use") + case errors.Is(err, ErrForbidden): + writeError(w, http.StatusForbidden, "insufficient permissions") + default: + writeError(w, http.StatusInternalServerError, "failed to create user") + } + return + } + writeJSON(w, http.StatusCreated, toUserDTO(result.User)) +} + +// GET /users, gets the list of users, having in mind the company and location +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + companyID := auth.CompanyIDFromContext(r.Context()) + requesterRole := auth.RoleFromContext(r.Context()) + requesterLocationID := locationIDFromContext(r.Context()) + + users, err := h.svc.List(r.Context(), companyID, string(requesterRole), requesterLocationID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch users") + return + } + + dtos := make([]userDTO, len(users)) + for i, u := range users { + dtos[i] = toUserDTO(u) + } + writeJSON(w, http.StatusOK, map[string]any{"users": dtos}) +} + +// GET /users/{id} +func (h *Handler) get(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + companyID := auth.CompanyIDFromContext(r.Context()) + + user, err := h.svc.Get(r.Context(), id, companyID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "user not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to fetch user") + return + } + writeJSON(w, http.StatusOK, toUserDTO(user)) +} + +// PUT /users/{id} +type updateRequest struct { + Name *string `json:"name"` + Role *string `json:"role"` + Phone *string `json:"phone"` + DefaultLocationID *int64 `json:"default_location_id"` +} + +func (h *Handler) update(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + var req updateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + companyID := auth.CompanyIDFromContext(r.Context()) + + user, err := h.svc.Update(r.Context(), id, companyID, UpdateUserInput{ + Name: req.Name, + Role: req.Role, + Phone: req.Phone, + DefaultLocationID: req.DefaultLocationID, + }) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "user not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to update user") + return + } + writeJSON(w, http.StatusOK, toUserDTO(user)) +} + +// DELETE /users/{id}, for now we would do a soft delete, but maybe we want to fully delete? +func (h *Handler) deactivate(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + companyID := auth.CompanyIDFromContext(r.Context()) + + if err := h.svc.Deactivate(r.Context(), id, companyID); err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "user not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to deactivate user") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "user deactivated"}) +} diff --git a/api/internal/user/repository.go b/api/internal/user/repository.go new file mode 100644 index 0000000..9a22845 --- /dev/null +++ b/api/internal/user/repository.go @@ -0,0 +1,285 @@ +package user + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type User struct { + ID int64 + CompanyID int64 + Name string + Email string + Phone *string + Role string + DefaultLocationID *int64 + NotificationPrefs map[string]any + CreatedAt time.Time + LastLoginAt *time.Time + IsActive bool +} + +type CreateUserInput struct { + CompanyID int64 + Name string + Email string + Phone *string + Role string + DefaultLocationID *int64 +} + +type UpdateUserInput struct { + Name *string + Role *string + DefaultLocationID *int64 + Phone *string +} + +type repository struct { + db *pgxpool.Pool +} + +func newRepository(db *pgxpool.Pool) *repository { + return &repository{db: db} +} + +func (r *repository) create(ctx context.Context, input CreateUserInput) (*User, error) { + const query = ` + INSERT INTO users (company_id, name, email, phone, role, default_location_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, company_id, name, email, phone, role, default_location_id, created_at, is_active + ` + + var u User + err := r.db.QueryRow(ctx, query, + input.CompanyID, + input.Name, + input.Email, + input.Phone, + input.Role, + input.DefaultLocationID, + ).Scan( + &u.ID, + &u.CompanyID, + &u.Name, + &u.Email, + &u.Phone, + &u.Role, + &u.DefaultLocationID, + &u.CreatedAt, + &u.IsActive, + ) + + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + + return &u, nil +} + +func (r *repository) getByEmail(ctx context.Context, email string, companyID int64) (*User, error) { + const query = ` + SELECT + id, + company_id, + name, + email, + phone, + role, + default_location_id, + created_at, + last_login_at, + is_active + FROM users + WHERE email = $1 AND company_id = $2 + LIMIT 1` + + var u User + err := r.db.QueryRow(ctx, query, email, companyID).Scan( + &u.ID, + &u.CompanyID, + &u.Name, + &u.Email, + &u.Phone, + &u.Role, + &u.DefaultLocationID, + &u.CreatedAt, + &u.LastLoginAt, + &u.IsActive, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get user by id: %w", err) + } + return &u, nil +} + +func (r *repository) getByID(ctx context.Context, id int64, companyID int64) (*User, error) { + const query = ` + SELECT + id, + company_id, + name, + email, + phone, + role, + default_location_id, + created_at, + last_login_at, + is_active + FROM users + WHERE id = $1 AND company_id = $2 + LIMIT 1` + + var u User + err := r.db.QueryRow(ctx, query, id, companyID).Scan( + &u.ID, + &u.CompanyID, + &u.Name, + &u.Email, + &u.Phone, + &u.Role, + &u.DefaultLocationID, + &u.CreatedAt, + &u.LastLoginAt, + &u.IsActive, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get user by id: %w", err) + } + return &u, nil +} + +// list returns all active users scoped to the company +func (r *repository) list(ctx context.Context, companyID int64, locationID *int64) ([]*User, error) { + query := ` + SELECT + id, + company_id, + name, + email, + phone, + role, + default_location_id, + created_at, + last_login_at, + is_active + FROM users + WHERE company_id = $1 AND is_active = true +` + + args := []any{companyID} + + if locationID != nil { + query += ` AND default_location_id = $2` + args = append(args, *locationID) + } + + query += ` ORDER BY name ASC` + + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var users []*User + for rows.Next() { + var u User + if err := rows.Scan( + &u.ID, + &u.CompanyID, + &u.Name, + &u.Email, + &u.Phone, + &u.Role, + &u.DefaultLocationID, + &u.CreatedAt, + &u.LastLoginAt, + &u.IsActive, + ); err != nil { + return nil, fmt.Errorf("list users: scan: %w", err) + } + users = append(users, &u) + } + return users, nil +} + +func (r *repository) update(ctx context.Context, id int64, companyID int64, input UpdateUserInput) (*User, error) { + const q = ` + UPDATE users + SET + name = COALESCE($3, name), + role = COALESCE($6, role), + default_location_id = COALESCE($7, default_location_id), + phone = COALESCE($5, phone) + WHERE id = $1 AND company_id = $2 + RETURNING + id, + company_id, + name, + email, + phone, + role, + default_location_id, + created_at, + last_login_at, + is_active` + + var u User + err := r.db.QueryRow(ctx, q, + id, + companyID, + input.Name, + input.Role, + input.DefaultLocationID, + input.Phone, + ).Scan( + &u.ID, + &u.CompanyID, + &u.Name, + &u.Email, + &u.Phone, + &u.Role, + &u.DefaultLocationID, + &u.CreatedAt, + &u.LastLoginAt, + &u.IsActive, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("update user: %w", err) + } + return &u, nil +} + +// TODO: do we want to soft delete the user are remove him for ever??? +// deactivate soft-deletes a user, preserves all history +func (r *repository) deactivate(ctx context.Context, id int64, companyID int64) error { + const query = ` + UPDATE users + SET is_active = false + WHERE id = $1 AND company_id = $2 +` + + tag, err := r.db.Exec(ctx, query, id, companyID) + if err != nil { + return fmt.Errorf("deactivate user: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/api/internal/user/service.go b/api/internal/user/service.go new file mode 100644 index 0000000..ea4af1a --- /dev/null +++ b/api/internal/user/service.go @@ -0,0 +1,136 @@ +package user + +import ( + "context" + "errors" + "fmt" + + "api/internal/auth" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrNotFound = errors.New("user not found") + ErrEmailTaken = errors.New("email already in use") + ErrForbidden = errors.New("insufficient permissions") +) + +type Service struct { + repo *repository + auth *auth.Service +} + +func NewService(db *pgxpool.Pool, authService *auth.Service) *Service { + return &Service{ + repo: newRepository(db), + auth: authService, + } +} + +type InviteInput struct { + CompanyID int64 + InviterRole string + InviterLocationID *int64 + Name string + Email string + Phone *string + Role string + DefaultLocationID *int64 +} + +type InviteResult struct { + User *User +} + +func dereferenceInt64(v *int64) any { + if v == nil { + return nil + } + return *v +} +func (s *Service) Invite(ctx context.Context, input InviteInput) (*InviteResult, error) { + // location manager can only create employees and technicias + if input.InviterRole == string(auth.RoleLocationManager) { + if input.Role != string(auth.RoleEmployee) && input.Role != string(auth.RoleTechnician) { + return nil, ErrForbidden + } + + // default to inviters location if not explicitly set + if input.DefaultLocationID == nil { + input.DefaultLocationID = input.InviterLocationID + } + + if input.InviterLocationID == nil || + *input.InviterLocationID != *input.DefaultLocationID { + return nil, ErrForbidden + } + } + // prevent duplicate emails within the company + _, err := s.repo.getByEmail(ctx, input.Email, input.CompanyID) + if err == nil { + return nil, ErrEmailTaken + } + if !errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("invite: check email: %w", err) + } + + user, err := s.repo.create(ctx, CreateUserInput{ + CompanyID: input.CompanyID, + Name: input.Name, + Email: input.Email, + Phone: input.Phone, + Role: input.Role, + DefaultLocationID: input.DefaultLocationID, + }) + + if err != nil { + return nil, fmt.Errorf("invite: create user: %w", err) + } + + // use our magic link system to send an invitation link to the user + _, err = s.auth.SendMagicLink(ctx, auth.SendMagicLinkInput{Email: user.Email}) + if err != nil { + // TODO: log this properly when logger is injected + // not fatal issue, user is created, invite email can be resent + fmt.Println("error magic link", err) + } + + return &InviteResult{User: user}, nil +} + +func (s *Service) List(ctx context.Context, companyID int64, requesterRole string, requesterLocationID *int64) ([]*User, error) { + // location managers only see their own locations users + var locationFilter *int64 + if requesterRole == string(auth.RoleLocationManager) { + locationFilter = requesterLocationID + } + + users, err := s.repo.list(ctx, companyID, locationFilter) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + return users, nil +} + +func (s *Service) Get(ctx context.Context, id int64, companyID int64) (*User, error) { + user, err := s.repo.getByID(ctx, id, companyID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return user, nil +} + +func (s *Service) Update(ctx context.Context, id int64, companyID int64, input UpdateUserInput) (*User, error) { + user, err := s.repo.update(ctx, id, companyID, input) + if err != nil { + return nil, fmt.Errorf("update user: %w", err) + } + return user, nil +} + +func (s *Service) Deactivate(ctx context.Context, id int64, companyID int64) error { + if err := s.repo.deactivate(ctx, id, companyID); err != nil { + return fmt.Errorf("deactivate user: %w", err) + } + return nil +} diff --git a/front/src/context/AuthContext.tsx b/front/src/context/AuthContext.tsx index a3a6511..b869fde 100644 --- a/front/src/context/AuthContext.tsx +++ b/front/src/context/AuthContext.tsx @@ -1,7 +1,7 @@ import type { FC, ReactNode } from 'react' import type { User } from '../types/index.ts' import { createContext, useContext, useEffect, useState } from 'react' -import { authApi, isApiError, tokenStore } from '../lib/auth.ts' +import { authApi, isApiError, tokenStore } from '../lib/api/auth.ts' interface AuthContextType { user: User | null diff --git a/front/src/lib/auth.ts b/front/src/lib/api/auth.ts similarity index 97% rename from front/src/lib/auth.ts rename to front/src/lib/api/auth.ts index 5ac8ee5..518b3df 100644 --- a/front/src/lib/auth.ts +++ b/front/src/lib/api/auth.ts @@ -1,4 +1,4 @@ -import type { User } from '../types/index' +import type { User } from '../../types/index.ts' const BASE = import.meta.env.VITE_API_URL ?? '' diff --git a/front/src/lib/api/user.ts b/front/src/lib/api/user.ts new file mode 100644 index 0000000..c6e4eaf --- /dev/null +++ b/front/src/lib/api/user.ts @@ -0,0 +1,65 @@ +import type { User } from '../../types/index.ts' +import { tokenStore } from './auth.ts' + +const BASE = import.meta.env.VITE_API_URL ?? '' + +const request = async (path: string, init: RequestInit = {}): Promise => { + const res = await fetch(`${BASE}${path}`, { + ...init, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(tokenStore.get() ? { Authorization: `Bearer ${tokenStore.get()}` } : {}), + ...init.headers, + }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error ?? res.statusText) + } + return res.json() as Promise +} + +export type UserDetail = { + id: number + company_id: number + name: string + email: string + phone: string | null + role: User['role'] + default_location_id: number | null + is_active: boolean +} + +export type InviteUserPayload = { + name: string + email: string + phone?: string + role: string + default_location_id?: number | null +} + +export type UpdateUserPayload = { + name?: string + role?: string + phone?: string + default_location_id?: number | null +} + +export const usersApi = { + list: () => request<{ users: UserDetail[] }>('/users').then((r) => r.users), + + invite: (payload: InviteUserPayload) => + request('/users', { + method: 'POST', + body: JSON.stringify(payload), + }), + + update: (id: number, payload: UpdateUserPayload) => + request(`/users/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }), + + deactivate: (id: number) => request<{ message: string }>(`/users/${id}`, { method: 'DELETE' }), +} diff --git a/front/src/main.tsx b/front/src/main.tsx index e44ecf4..50a6130 100644 --- a/front/src/main.tsx +++ b/front/src/main.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client' import App from './App.tsx' import { Provider as UrqlProvider } from 'urql' import { gqlClient, setTokenGetter } from './lib/graphql-client.ts' -import { tokenStore } from './lib/auth.ts' +import { tokenStore } from './lib/api/auth.ts' import './index.css' import './i18n' diff --git a/front/src/pages/Login.tsx b/front/src/pages/Login.tsx index 2d370c0..ca54e93 100644 --- a/front/src/pages/Login.tsx +++ b/front/src/pages/Login.tsx @@ -7,7 +7,7 @@ import { CheckCircle2, Globe, Loader2, Mail, Moon, QrCode, Sun } from 'lucide-re import { Input } from '../components/Input.tsx' import { useTranslation } from 'react-i18next' import { useTheme } from '../context/ThemeContext.tsx' -import { authApi } from '../lib/auth.ts' +import { authApi } from '../lib/api/auth.ts' type LoginMode = 'password' | 'magic-link' @@ -111,10 +111,11 @@ export const Login: FC = () => { setMagicLinkSent(false) setMagicLinkError(null) }} - className={`flex-1 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${loginMode === m + className={`flex-1 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${ + loginMode === m ? 'bg-surface shadow-sm text-on-surface' : 'text-on-surface-variant hover:text-on-surface' - }`} + }`} > {m === 'password' ? t('login.mode_password') : t('login.mode_magic_link')} @@ -158,7 +159,11 @@ export const Login: FC = () => {

{error}

)} - - - ) : ( -
-

- {t('login.magic_link_description')} -

- setEmail(e.target.value)} - autoComplete='email' - required - /> - - {magicLinkError && ( -

- {magicLinkError} + +

+

+ {t('login.magic_link_sent')} +

+

+ {t('login.magic_link_check_inbox')}{' '} + {email} +

+
+ + + ) + : ( + +

+ {t('login.magic_link_description')}

- )} - - -
- )} + setEmail(e.target.value)} + autoComplete='email' + required + /> + + {magicLinkError && ( +

+ {magicLinkError} +

+ )} + + + + )} )} @@ -235,7 +249,10 @@ export const Login: FC = () => {

{t('login.restricted_access')}{' '} -

diff --git a/front/src/pages/Settings.tsx b/front/src/pages/Settings.tsx index 2f370c0..31bb550 100644 --- a/front/src/pages/Settings.tsx +++ b/front/src/pages/Settings.tsx @@ -1,89 +1,90 @@ import type { ColorTheme } from '../context/ThemeContext.tsx' -import { type FC, type ReactNode, useState } from 'react' +import { type FC, type ReactNode, useEffect, useState } from 'react' import { useTheme } from '../context/ThemeContext.tsx' import { useAuth } from '../context/AuthContext.tsx' import { useToast } from '../context/ToastContext.tsx' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/Card.tsx' import { Button } from '../components/Button.tsx' -import { Input } from '../components/Input.tsx' -import { MapPin, Palette, Plus, ShieldCheck, Trash2, User as UserIcon, Users } from 'lucide-react' +import { Input, Select } from '../components/Input.tsx' +import { + Loader2, + MapPin, + Palette, + Plus, + ShieldCheck, + Trash2, + User as UserIcon, + Users, + X, +} from 'lucide-react' import { Badge } from '../components/Badge.tsx' -import { LOCATIONS, USERS } from '../data/mockData.ts' import { cn } from '../lib/utils.ts' import { useTranslation } from 'react-i18next' +import { type InviteUserPayload, type UserDetail, usersApi } from '../lib/api/user.ts' +import { AnimatePresence, motion } from 'framer-motion' + +const ROLES = ['employee', 'technician', 'location_manager', 'ops_manager', 'admin'] as const -/** - * Settings page for user profile, system preferences, and administrative management. - * Organised into tabs for better navigation between different configuration areas. - */ export const Settings: FC = () => { const { t } = useTranslation() const { user } = useAuth() const { mode, colorTheme, toggleMode, setColorTheme } = useTheme() const { toast } = useToast() - const [activeTab, setActiveTab] = useState< - 'profile' | 'users' | 'locations' | 'roles' - >('profile') + const [activeTab, setActiveTab] = useState<'profile' | 'users' | 'locations' | 'roles'>('profile') - // Determine if the current user has administrative permissions - const isAdmin = user?.role === 'admin' || user?.role === 'ops_manager' + const isAdmin = user?.role === 'admin' || user?.role === 'ops_manager' || + user?.role === 'location_manager' - /** - * List of available colour themes for the application. - */ - const themes: { id: ColorTheme; color: string; name: string }[] = [ - { - id: 'gold', - color: 'bg-[#b3922d] dark:bg-[#f2ca50]', - name: t('settings.theme_gold'), - }, - { - id: 'sapphire', - color: 'bg-[#0284c7] dark:bg-[#38bdf8]', - name: t('settings.theme_sapphire'), - }, - { - id: 'emerald', - color: 'bg-[#059669] dark:bg-[#34d399]', - name: t('settings.theme_emerald'), - }, - { - id: 'ruby', - color: 'bg-[#e11d48] dark:bg-[#fb7185]', - name: t('settings.theme_ruby'), - }, - { - id: 'amethyst', - color: 'bg-[#7c3aed] dark:bg-[#a78bfa]', - name: t('settings.theme_amethyst'), - }, - ] + const [users, setUsers] = useState([]) + const [usersLoading, setUsersLoading] = useState(false) + const [showInviteModal, setShowInviteModal] = useState(false) + const [deactivatingId, setDeactivatingId] = useState(null) - /** - * Save profile changes and show a confirmation toast. - */ - const handleSaveProfile = () => { - toast('Profile settings saved successfully!', 'success') + useEffect(() => { + if (activeTab !== 'users' || !isAdmin) return + setUsersLoading(true) + usersApi + .list() + .then(setUsers) + .catch(() => toast('Failed to load users', 'error')) + .finally(() => setUsersLoading(false)) + }, [activeTab]) + + const handleDeactivate = async (u: UserDetail) => { + if (!confirm(`Deactivate ${u.name}? They will no longer be able to log in.`)) return + setDeactivatingId(u.id) + try { + await usersApi.deactivate(u.id) + setUsers((prev) => prev.filter((x) => x.id !== u.id)) + toast(`${u.name} has been deactivated`, 'success') + } catch { + toast('Failed to deactivate user', 'error') + } finally { + setDeactivatingId(null) + } } - /** - * Update the application's primary accent colour. - */ - const handleThemeChange = (id: ColorTheme, name: string) => { - setColorTheme(id) - toast(`Theme updated to ${name}`, 'info') + const handleInvited = (newUser: UserDetail) => { + setUsers((prev) => [newUser, ...prev]) + setShowInviteModal(false) + toast(`Invite sent to ${newUser.email}`, 'success') } + const themes: { id: ColorTheme; color: string; name: string }[] = [ + { id: 'gold', color: 'bg-[#b3922d] dark:bg-[#f2ca50]', name: t('settings.theme_gold') }, + { id: 'sapphire', color: 'bg-[#0284c7] dark:bg-[#38bdf8]', name: t('settings.theme_sapphire') }, + { id: 'emerald', color: 'bg-[#059669] dark:bg-[#34d399]', name: t('settings.theme_emerald') }, + { id: 'ruby', color: 'bg-[#e11d48] dark:bg-[#fb7185]', name: t('settings.theme_ruby') }, + { id: 'amethyst', color: 'bg-[#7c3aed] dark:bg-[#a78bfa]', name: t('settings.theme_amethyst') }, + ] + return (
-

- {t('settings.title')} -

+

{t('settings.title')}

{t('settings.subtitle')}

- {/* Tab navigation bar */}
{
- {/* Profile Management Section */} + {/* Profile */} {activeTab === 'profile' && ( @@ -131,21 +132,13 @@ export const Settings: FC = () => {
- {user?.avatar - ? ( - {`${user?.name}'s - ) - : } +
@@ -154,43 +147,20 @@ export const Settings: FC = () => {

-
- - + + - -
- -
-
)} - {/* Administrative User Management */} + {/* Users */} {activeTab === 'users' && isAdmin && ( @@ -198,124 +168,86 @@ export const Settings: FC = () => { {t('settings.user_management')} - - {t('settings.user_management_desc')} - + {t('settings.user_management_desc')}
- -
- {USERS.map((u) => ( -
-
-
- {u.avatar - ? ( - {`${u.name}'s + +
+ ) + : users.length === 0 + ? ( +

+ No users found. +

+ ) + : ( +
+ {users.map((u) => ( +
+
+
+ +
+
+

{u.name}

+

{u.email}

+
+
+
+ - ) - : } -
-
-

- {u.name} -

-

- {u.email} -

-
+ {user?.role === 'admin' && u.id !== user?.id && ( + + )} +
+
+ ))}
-
- - -
-
- ))} -
+ )}
)} - {/* Location and Site Configuration */} + {/* Locations, kept as-is, will be wired to API in locations module */} {activeTab === 'locations' && isAdmin && ( - -
- - {t('common.locations')} - - - {t('settings.locations_desc')} - -
- + + + {t('common.locations')} + + {t('settings.locations_desc')} -
- {LOCATIONS.slice(0, 5).map((l) => ( -
-
-
- -
-
-

- {l.name} -

-

- {l.type} -

-
-
- -
- ))} -
+

Location management coming soon.

)}
- {/* System Preferences Sidebar */} + {/* Preferences sidebar */}
@@ -324,27 +256,21 @@ export const Settings: FC = () => { - {/* Appearance Mode */}

{t('settings.dark_mode')}

-

- {t('settings.dark_mode_desc')} -

+

{t('settings.dark_mode_desc')}

- {/* Primary Accent Selection */}

{t('settings.accent_color')}

- {themes.map((t) => ( + {themes.map((th) => (
- - {/* Notification Settings */} -
-
-

- {t('settings.notifications')} -

-

- {t('settings.notifications_desc')} -

-
- -
- {/* System Integrity Badge */} @@ -412,36 +320,130 @@ export const Settings: FC = () => { -
-

- {t('common.status')}: {t('common.operational')} -

-

- Version: 2.4.0-Enterprise -

-
+

+ {t('common.status')}: {t('common.operational')} +

+

+ Version: 2.4.0-Enterprise +

+ + {/* Invite modal */} + + {showInviteModal && ( + setShowInviteModal(false)} + onInvited={handleInvited} + /> + )} + +
+ ) +} + +const InviteModal: FC<{ onClose: () => void; onInvited: (u: UserDetail) => void }> = ( + { onClose, onInvited }, +) => { + const [form, setForm] = useState({ name: '', email: '', role: 'employee' }) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + const set = + (field: keyof InviteUserPayload) => + (e: React.ChangeEvent) => + setForm((prev) => ({ ...prev, [field]: e.target.value })) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setIsSubmitting(true) + try { + const user = await usersApi.invite(form) + onInvited(user) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send invite') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ + +
+

Invite User

+ +
+ +
+ + + + + + {error &&

{error}

} + +
+ + +
+
+
) } -/** - * Reusable navigation tab button. - */ -const TabButton = ({ - active, - onClick, - label, - icon, -}: { - active: boolean - onClick: () => void - label: string - icon: ReactNode -}) => ( +const TabButton = ( + { active, onClick, label, icon }: { + active: boolean + onClick: () => void + label: string + icon: ReactNode + }, +) => (

) : users.length === 0 - ? ( -

- No users found. -

- ) - : ( -
- {users.map((u) => ( -
-
-
- -
-
-

{u.name}

-

{u.email}

-
+ ? ( +

+ No users found. +

+ ) + : ( +
+ {users.map((u) => ( +
+
+
+
-
- - {user?.role === 'admin' && u.id !== user?.id && ( - - )} +
+

{u.name}

+

{u.email}

- ))} -
- )} +
+ + {user?.role === 'admin' && u.id !== user?.id && ( + + )} +
+
+ ))} +
+ )} )} @@ -353,8 +353,8 @@ const InviteModal: FC<{ onClose: () => void; onInvited: (u: UserDetail) => void const set = (field: keyof InviteUserPayload) => - (e: React.ChangeEvent) => - setForm((prev) => ({ ...prev, [field]: e.target.value })) + (e: React.ChangeEvent) => + setForm((prev) => ({ ...prev, [field]: e.target.value })) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault()