Phase 7: Apply design system to all admin pages + fix test stubs

Frontend — consistent HSL token usage across remaining pages:
- Users: shared Card, Badge with success/error tokens, h2 typography, animate-fadeIn
- Audit: shared Card, muted-foreground text, animate-fadeIn
- Clients: shared Card, Badge active/inactive, hsl(--primary) icon color
- Storage: shared Card, StatusBadge for status pills, hsl warning/primary
  bars replacing hardcoded amber/blue, all gray text → muted-foreground
- Login: hsl(--surface) bg, hsl(--primary) submit button, brand mark icon,
  animate-scaleIn card entry, hsl(--warning) dev notice

Backend tests — convert print-only stubs to real assertions:
- test_pptx_creator: mkdir, deterministic save path, assert file exists + slide count
- test_gemini_schema_support: direct google.genai client, skipif guard on
  GOOGLE_API_KEY, JSON parse + Pydantic model validation assertions
- test_openai_schema_support: clean skip (OpenAI removed in Phase 6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-01 19:01:52 +00:00
parent a74f533043
commit 5def8f9e84
8 changed files with 171 additions and 194 deletions

View file

@ -1,28 +1,8 @@
import json
import os
import pytest
from typing import Optional
from pydantic import BaseModel, Field
from google.genai.types import GenerateContentResponse, GenerateContentConfig
from utils.llm_provider import get_google_llm_client, get_large_model
class HeadingDescription(BaseModel):
heading: str = Field(
description="Heading of the slide", min_length=10, max_length=20
)
description: str = Field(
description="Description of the slide", min_length=40, max_length=200
)
class SlideContentTest(BaseModel):
title: str = Field(description="Title of the slide", min_length=10, max_length=20)
first_content: HeadingDescription = Field(description="First content of the slide")
second_content: HeadingDescription = Field(
description="Second content of the slide"
)
third_content: HeadingDescription = Field(description="Third content of the slide")
class ColumnContentModel(BaseModel):
@ -37,34 +17,55 @@ class TwoColumnSlideModel(BaseModel):
description="Title of the slide",
)
subtitle: Optional[str] = Field(
default=None,
min_length=3,
max_length=150,
description="Optional subtitle or description",
)
leftColumn: ColumnContentModel = Field(
description="Left column content",
)
rightColumn: ColumnContentModel = Field(
description="Right column content",
)
leftColumn: ColumnContentModel = Field(description="Left column content")
rightColumn: ColumnContentModel = Field(description="Right column content")
backgroundImage: Optional[str] = Field(
description="URL to background image for the slide"
default=None,
description="URL to background image for the slide",
)
@pytest.mark.skipif(
not os.getenv("GOOGLE_API_KEY"),
reason="GOOGLE_API_KEY not set — skipping Gemini API test",
)
def test_gemini_schema_support():
response: GenerateContentResponse = get_google_llm_client().models.generate_content(
model=get_large_model(),
import google.genai as genai
from google.genai.types import GenerateContentConfig
client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
model = os.getenv("GOOGLE_MODEL", "gemini-2.0-flash")
response = client.models.generate_content(
model=model,
contents=[
"Generate a slide for a presentation",
"The slide should have a title and two contents",
"The title should be a short title for the slide",
"The contents should be a heading and a description",
"The heading should be a short heading for the slide",
"Generate a slide for a presentation about renewable energy.",
"The slide should have a title, subtitle, and two columns (left and right).",
],
config=GenerateContentConfig(
response_mime_type="application/json",
response_schema=TwoColumnSlideModel.model_json_schema(),
),
)
print(response.text)
assert response is not None, "Response from Gemini was None"
assert response.text, "Response text is empty"
data = json.loads(response.text)
assert "title" in data, "Response missing 'title' field"
assert "leftColumn" in data, "Response missing 'leftColumn' field"
assert "rightColumn" in data, "Response missing 'rightColumn' field"
assert isinstance(data["leftColumn"], dict), "'leftColumn' should be an object"
assert "title" in data["leftColumn"], "'leftColumn' missing 'title'"
assert "content" in data["leftColumn"], "'leftColumn' missing 'content'"
# Validate full model parse
slide = TwoColumnSlideModel.model_validate(data)
assert len(slide.title) >= 3, "Title too short"
assert len(slide.leftColumn.content) >= 10, "Left column content too short"
assert len(slide.rightColumn.content) >= 10, "Right column content too short"

View file

@ -1,66 +1,10 @@
import asyncio
import json
from typing import Optional
from pydantic import BaseModel, Field
from utils.llm_provider import get_llm_client, get_large_model
class HeadingDescription(BaseModel):
heading: str = Field(
description="Heading of the slide", min_length=10, max_length=20
)
description: str = Field(
description="Description of the slide", min_length=40, max_length=200
)
class SlideContentTest(BaseModel):
title: str = Field(description="Title of the slide", min_length=10, max_length=20)
first_content: HeadingDescription = Field(description="First content of the slide")
second_content: HeadingDescription = Field(
description="Second content of the slide"
)
third_content: HeadingDescription = Field(description="Third content of the slide")
class ColumnContentModel(BaseModel):
title: str = Field(min_length=3, max_length=100, description="Column title")
content: str = Field(min_length=10, max_length=800, description="Column content")
class TwoColumnSlideModel(BaseModel):
title: str = Field(
min_length=3,
max_length=100,
description="Title of the slide",
)
subtitle: Optional[str] = Field(
min_length=3,
max_length=150,
description="Optional subtitle or description",
)
leftColumn: ColumnContentModel = Field(
description="Left column content",
)
rightColumn: ColumnContentModel = Field(
description="Right column content",
)
backgroundImage: Optional[str] = Field(
description="URL to background image for the slide"
)
import pytest
# This project uses a Gemini-only LLM stack (Phase 6 migration).
# OpenAI structured output is no longer part of the stack.
# See test_gemini_schema_support.py for the equivalent test.
@pytest.mark.skip(reason="OpenAI removed in Phase 6 Gemini-only migration — see test_gemini_schema_support.py")
def test_openai_schema_support():
response = asyncio.run(
get_llm_client().beta.chat.completions.parse(
model=get_large_model(),
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Generate a slide for a presentation"},
],
response_format=TwoColumnSlideModel,
)
)
print(response.choices[0].message.parsed)
pass

View file

@ -1,4 +1,5 @@
import asyncio
import os
from models.pptx_models import (
PptxAutoShapeBoxModel,
PptxFillModel,
@ -8,6 +9,7 @@ from models.pptx_models import (
)
from services.pptx_presentation_creator import PptxPresentationCreator
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx import Presentation
pptx_model = PptxPresentationModel(
@ -34,7 +36,16 @@ pptx_model = PptxPresentationModel(
def test_pptx_creator():
temp_dir = "/tmp/deckforge"
temp_dir = "/tmp/deckforge_test"
os.makedirs(temp_dir, exist_ok=True)
save_path = "/tmp/deckforge_test/test_output.pptx"
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
asyncio.run(pptx_creator.create_ppt())
pptx_creator.save("debug/test.pptx")
pptx_creator.save(save_path)
assert os.path.exists(save_path), f"PPTX file was not created at {save_path}"
assert os.path.getsize(save_path) > 0, "PPTX file is empty"
prs = Presentation(save_path)
assert len(prs.slides) == 1, f"Expected 1 slide, got {len(prs.slides)}"

View file

@ -14,6 +14,7 @@ import {
} from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/shared/Card';
import DataExportButton from '../components/DataExportButton';
import { Search } from 'lucide-react';
@ -40,9 +41,9 @@ export default function AuditLogPage() {
};
return (
<div className="space-y-4">
<div className="space-y-4 animate-fadeIn">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Audit Log</h1>
<h1 className="h2">Audit Log</h1>
<DataExportButton endpoint="/api/v1/admin/audit-log/export" />
</div>
@ -58,11 +59,11 @@ export default function AuditLogPage() {
</Button>
</div>
<div className="bg-white rounded-lg border">
<Card className="p-0 overflow-hidden">
{loading ? (
<div className="space-y-3 p-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-10 bg-gray-200 rounded animate-pulse" />
<div key={i} className="h-10 bg-[hsl(var(--surface-hover))] rounded animate-pulse" />
))}
</div>
) : (
@ -79,18 +80,18 @@ export default function AuditLogPage() {
<TableBody>
{auditLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-sm text-gray-500">
<TableCell className="text-sm text-muted-foreground">
{log.created_at ? new Date(log.created_at).toLocaleString() : '—'}
</TableCell>
<TableCell className="font-mono text-sm">{log.action}</TableCell>
<TableCell>{log.resource_type}</TableCell>
<TableCell className="text-sm">{log.user_id || 'System'}</TableCell>
<TableCell className="text-sm text-gray-500">{log.ip_address || '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">{log.ip_address || '—'}</TableCell>
</TableRow>
))}
{auditLogs.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-gray-500 py-8">
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
No audit log entries found.
</TableCell>
</TableRow>
@ -98,7 +99,7 @@ export default function AuditLogPage() {
</TableBody>
</Table>
)}
</div>
</Card>
</div>
);
}

View file

@ -5,7 +5,8 @@ import { useDispatch, useSelector } from 'react-redux';
import { AppDispatch, RootState } from '@/store/store';
import { fetchClients } from '@/store/slices/adminSlice';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/shared/Card';
import { Plus, Building2 } from 'lucide-react';
import Link from 'next/link';
import CreateClientDialog from '../components/CreateClientDialog';
@ -22,11 +23,11 @@ export default function ClientsPage() {
if (isLoading) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Clients</h1>
<div className="space-y-4 animate-fadeIn">
<h1 className="h2">Clients</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-36 bg-gray-200 rounded-lg animate-pulse" />
<div key={i} className="h-36 bg-[hsl(var(--surface-hover))] rounded-lg animate-pulse" />
))}
</div>
</div>
@ -34,9 +35,9 @@ export default function ClientsPage() {
}
return (
<div className="space-y-4">
<div className="space-y-4 animate-fadeIn">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Clients</h1>
<h1 className="h2">Clients</h1>
{user?.role === 'super_admin' && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
@ -46,8 +47,8 @@ export default function ClientsPage() {
</div>
{clients.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<Building2 className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<div className="text-center py-12 text-muted-foreground">
<Building2 className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No clients yet.</p>
{user?.role === 'super_admin' && (
<p className="text-sm mt-1">Create your first client to get started.</p>
@ -57,21 +58,28 @@ export default function ClientsPage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{clients.map((client) => (
<Link key={client.id} href={`/admin/clients/${client.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2">
<Card hover className="p-5">
<CardHeader className="mb-2 pb-0">
<CardTitle className="text-lg flex items-center gap-2">
<Building2 className="w-5 h-5 text-[#5146E5]" />
<Building2 className="w-5 h-5 text-[hsl(var(--primary))]" />
{client.name}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>/{client.slug}</span>
<span className={client.is_active ? 'text-green-600' : 'text-red-600'}>
<Badge
className={
client.is_active
? 'bg-[hsl(var(--success)/0.1)] text-[hsl(var(--success))] border-[hsl(var(--success)/0.2)]'
: 'bg-[hsl(var(--error)/0.1)] text-[hsl(var(--error))] border-[hsl(var(--error)/0.2)]'
}
variant="outline"
>
{client.is_active ? 'Active' : 'Inactive'}
</span>
</Badge>
</div>
<p className="text-xs text-gray-400 mt-2">
<p className="text-xs text-muted-foreground mt-2">
Policy: {client.review_policy}
</p>
</CardContent>

View file

@ -16,7 +16,8 @@ import {
AlertTriangle,
RefreshCw,
} from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Card } from '@/components/shared/Card';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -68,11 +69,6 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-yellow-100 text-yellow-700',
in_review: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700',
};
export default function StoragePage() {
const user = useSelector((state: RootState) => state.auth.user);
@ -219,7 +215,7 @@ export default function StoragePage() {
}
return (
<div className="space-y-6">
<div className="space-y-6 animate-fadeIn">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Storage</h1>
<div className="flex items-center gap-3">
@ -251,14 +247,14 @@ export default function StoragePage() {
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-fadeIn">
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">Presentations</p>
<p className="text-sm text-muted-foreground">Presentations</p>
<p className="text-2xl font-bold mt-1">{summary.total_presentations}</p>
</div>
<div className="p-2 rounded-lg bg-gray-50 text-[#5146E5]">
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
<FileText className="w-5 h-5" />
</div>
</div>
@ -266,10 +262,10 @@ export default function StoragePage() {
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">Export Files</p>
<p className="text-sm text-muted-foreground">Export Files</p>
<p className="text-2xl font-bold mt-1">{summary.total_files}</p>
</div>
<div className="p-2 rounded-lg bg-gray-50 text-blue-600">
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
<FolderOpen className="w-5 h-5" />
</div>
</div>
@ -277,15 +273,15 @@ export default function StoragePage() {
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">Master Decks</p>
<p className="text-sm text-muted-foreground">Master Decks</p>
<p className="text-2xl font-bold mt-1">
{summary.total_master_decks}
<span className="text-sm font-normal text-gray-400 ml-1">
<span className="text-sm font-normal text-muted-foreground ml-1">
({summary.master_deck_files} files)
</span>
</p>
</div>
<div className="p-2 rounded-lg bg-gray-50 text-purple-600">
<div className="p-2 rounded-lg bg-[hsl(var(--primary-light))] text-[hsl(var(--primary))]">
<Layers className="w-5 h-5" />
</div>
</div>
@ -293,12 +289,12 @@ export default function StoragePage() {
<Card className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">Total Size</p>
<p className="text-sm text-muted-foreground">Total Size</p>
<p className="text-2xl font-bold mt-1">
{formatBytes(summary.total_size_bytes + (summary.master_deck_size_bytes || 0))}
</p>
</div>
<div className="p-2 rounded-lg bg-gray-50 text-green-600">
<div className="p-2 rounded-lg bg-[hsl(var(--success)/0.1)] text-[hsl(var(--success))]">
<HardDrive className="w-5 h-5" />
</div>
</div>
@ -308,8 +304,8 @@ export default function StoragePage() {
{/* Soft-deleted notice + purge */}
{summary && summary.total_deleted > 0 && isSuperAdmin && (
<div className="flex items-center justify-between p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-center gap-2 text-sm text-amber-700">
<div className="flex items-center justify-between p-3 bg-[hsl(var(--warning)/0.08)] border border-[hsl(var(--warning)/0.3)] rounded-lg">
<div className="flex items-center gap-2 text-sm text-[hsl(var(--warning))]">
<AlertTriangle className="w-4 h-4" />
<span>{summary.total_deleted} soft-deleted presentation(s) with files still on disk.</span>
</div>
@ -318,7 +314,7 @@ export default function StoragePage() {
size="sm"
onClick={handlePurge}
disabled={purging}
className="text-amber-700 border-amber-300 hover:bg-amber-100"
className="text-[hsl(var(--warning))] border-[hsl(var(--warning)/0.3)] hover:bg-[hsl(var(--warning)/0.08)]"
>
{purging ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Trash2 className="w-3.5 h-3.5 mr-1" />}
Purge Files
@ -328,8 +324,8 @@ export default function StoragePage() {
{/* Bulk actions toolbar */}
{selectedIds.size > 0 && (
<div className="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<span className="text-sm text-blue-700 font-medium">
<div className="flex items-center gap-3 p-3 bg-[hsl(var(--primary)/0.06)] border border-[hsl(var(--primary)/0.2)] rounded-lg">
<span className="text-sm text-[hsl(var(--primary))] font-medium">
{selectedIds.size} selected
</span>
<Button
@ -355,9 +351,9 @@ export default function StoragePage() {
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
<tr className="border-b bg-[hsl(var(--surface))]">
<th className="p-3 w-10">
<button onClick={toggleSelectAll} className="text-gray-400 hover:text-gray-600">
<button onClick={toggleSelectAll} className="text-muted-foreground hover:text-foreground">
{selectedIds.size === presentations.length && presentations.length > 0 ? (
<CheckSquare className="w-4 h-4" />
) : (
@ -365,18 +361,18 @@ export default function StoragePage() {
)}
</button>
</th>
<th className="text-left p-3 font-medium text-gray-600">Title</th>
<th className="text-left p-3 font-medium text-gray-600">Status</th>
<th className="text-left p-3 font-medium text-gray-600">Created</th>
<th className="text-right p-3 font-medium text-gray-600">Files</th>
<th className="text-right p-3 font-medium text-gray-600">Size</th>
<th className="text-right p-3 font-medium text-gray-600">Actions</th>
<th className="text-left p-3 font-medium text-muted-foreground">Title</th>
<th className="text-left p-3 font-medium text-muted-foreground">Status</th>
<th className="text-left p-3 font-medium text-muted-foreground">Created</th>
<th className="text-right p-3 font-medium text-muted-foreground">Files</th>
<th className="text-right p-3 font-medium text-muted-foreground">Size</th>
<th className="text-right p-3 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{presentations.length === 0 ? (
<tr>
<td colSpan={7} className="p-8 text-center text-gray-400">
<td colSpan={7} className="p-8 text-center text-muted-foreground">
No presentations found.
</td>
</tr>
@ -384,12 +380,12 @@ export default function StoragePage() {
presentations.map((p) => (
<tr
key={p.id}
className={`border-b last:border-0 hover:bg-gray-50 ${selectedIds.has(p.id) ? 'bg-blue-50' : ''}`}
className={`border-b last:border-0 hover:bg-[hsl(var(--surface))] ${selectedIds.has(p.id) ? 'bg-[hsl(var(--primary)/0.05)]' : ''}`}
>
<td className="p-3">
<button onClick={() => toggleSelect(p.id)} className="text-gray-400 hover:text-gray-600">
<button onClick={() => toggleSelect(p.id)} className="text-muted-foreground hover:text-foreground">
{selectedIds.has(p.id) ? (
<CheckSquare className="w-4 h-4 text-blue-600" />
<CheckSquare className="w-4 h-4 text-[hsl(var(--primary))]" />
) : (
<Square className="w-4 h-4" />
)}
@ -399,15 +395,13 @@ export default function StoragePage() {
<span className="font-medium">{p.title || 'Untitled'}</span>
</td>
<td className="p-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[p.status] || 'bg-gray-100 text-gray-600'}`}>
{p.status}
</span>
<StatusBadge status={p.status as 'draft' | 'in_review' | 'approved'} />
</td>
<td className="p-3 text-gray-500">
<td className="p-3 text-muted-foreground">
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
</td>
<td className="p-3 text-right text-gray-500">{p.file_count}</td>
<td className="p-3 text-right text-gray-500">
<td className="p-3 text-right text-muted-foreground">{p.file_count}</td>
<td className="p-3 text-right text-muted-foreground">
{p.total_size_bytes > 0 ? formatBytes(p.total_size_bytes) : '—'}
</td>
<td className="p-3 text-right">
@ -428,7 +422,7 @@ export default function StoragePage() {
onClick={() => setDeleteTarget(p)}
title="Delete"
>
<Trash2 className="w-4 h-4 text-red-500" />
<Trash2 className="w-4 h-4 text-[hsl(var(--error))]" />
</Button>
</div>
</td>
@ -446,7 +440,7 @@ export default function StoragePage() {
<DialogHeader>
<DialogTitle>Delete Presentation</DialogTitle>
</DialogHeader>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Are you sure you want to delete &quot;{deleteTarget?.title || 'Untitled'}&quot;?
Associated files will be cleaned up by the retention service.
</p>
@ -467,7 +461,7 @@ export default function StoragePage() {
<DialogHeader>
<DialogTitle>Delete {selectedIds.size} Presentations</DialogTitle>
</DialogHeader>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Are you sure you want to delete {selectedIds.size} selected presentations?
This action can be undone by an admin.
</p>

View file

@ -21,6 +21,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/shared/Card';
import { toast } from 'sonner';
export default function UsersPage() {
@ -53,11 +55,11 @@ export default function UsersPage() {
if (isLoading) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Users</h1>
<div className="space-y-4 animate-fadeIn">
<h1 className="h2">Users</h1>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded animate-pulse" />
<div key={i} className="h-12 bg-[hsl(var(--surface-hover))] rounded animate-pulse" />
))}
</div>
</div>
@ -65,9 +67,9 @@ export default function UsersPage() {
}
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Users</h1>
<div className="bg-white rounded-lg border">
<div className="space-y-4 animate-fadeIn">
<h1 className="h2">Users</h1>
<Card className="p-0 overflow-hidden">
<Table>
<TableHeader>
<TableRow>
@ -86,11 +88,18 @@ export default function UsersPage() {
<TableCell>{user.email}</TableCell>
<TableCell><RoleBadge role={user.role} /></TableCell>
<TableCell>
<span className={`text-xs ${user.is_active ? 'text-green-600' : 'text-red-600'}`}>
<Badge
className={
user.is_active
? 'bg-[hsl(var(--success)/0.1)] text-[hsl(var(--success))] border-[hsl(var(--success)/0.2)]'
: 'bg-[hsl(var(--error)/0.1)] text-[hsl(var(--error))] border-[hsl(var(--error)/0.2)]'
}
variant="outline"
>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-500">
<TableCell className="text-sm text-muted-foreground">
{user.last_login_at
? new Date(user.last_login_at).toLocaleDateString()
: 'Never'}
@ -115,7 +124,7 @@ export default function UsersPage() {
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700"
className="text-[hsl(var(--error))] hover:text-[hsl(var(--error))] hover:border-[hsl(var(--error)/0.4)]"
onClick={() => handleDeactivate(user.id)}
>
Deactivate
@ -128,14 +137,14 @@ export default function UsersPage() {
))}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-gray-500 py-8">
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No users found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
</div>
);
}

View file

@ -52,14 +52,23 @@ export default function LoginPage() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
<div className="min-h-screen bg-[hsl(var(--surface))] flex items-center justify-center p-4">
<div className="max-w-md w-full animate-scaleIn">
<div className="bg-white rounded-2xl shadow-xl border border-border p-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900 font-inter">
{/* Brand mark */}
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-[hsl(var(--primary-light))] mb-4">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="8" height="8" rx="1.5" fill="hsl(var(--primary))" />
<rect x="13" y="3" width="8" height="8" rx="1.5" fill="hsl(var(--primary))" opacity="0.6" />
<rect x="3" y="13" width="8" height="8" rx="1.5" fill="hsl(var(--primary))" opacity="0.4" />
<rect x="13" y="13" width="8" height="8" rx="1.5" fill="hsl(var(--primary))" opacity="0.8" />
</svg>
</div>
<h1 className="text-2xl font-bold text-foreground font-inter">
OLIVER DeckForge
</h1>
<p className="text-sm text-gray-500 mt-2">
<p className="text-sm text-muted-foreground mt-2">
AI-powered presentation generation for enterprise teams
</p>
</div>
@ -81,8 +90,8 @@ export default function LoginPage() {
{isDevMode && (
<form onSubmit={handleDevLogin} className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<p className="text-xs text-amber-700 font-medium">
<div className="bg-[hsl(var(--warning)/0.08)] border border-[hsl(var(--warning)/0.3)] rounded-lg p-3 mb-4">
<p className="text-xs text-[hsl(var(--warning))] font-medium">
Development Mode Azure AD not configured
</p>
</div>
@ -90,7 +99,7 @@ export default function LoginPage() {
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-foreground mb-1"
>
Email
</label>
@ -100,7 +109,7 @@ export default function LoginPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full rounded-lg border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:border-transparent"
placeholder="admin@deckforge.dev"
/>
</div>
@ -108,7 +117,7 @@ export default function LoginPage() {
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-foreground mb-1"
>
Password
</label>
@ -118,19 +127,19 @@ export default function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="devpass123"
className="w-full rounded-lg border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
<p className="text-sm text-[hsl(var(--error))]">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white rounded-lg px-4 py-3 font-medium hover:bg-blue-700 transition-colors disabled:opacity-50"
className="w-full bg-[hsl(var(--primary))] text-white rounded-lg px-4 py-3 font-medium hover:bg-[hsl(var(--primary-hover))] transition-colors disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in (Development)'}
</button>