feat: multi-tenant dashboard shell -- auth, onboarding, organizations, tasks, team

- login page (Supabase email/password sign-in + sign-up)
- onboarding: create first tenant, redirects into /dashboard once a
  membership exists
- dashboard shell: sidebar nav, tenant switcher (x-tenant-id header,
  never in body/query -- matches ceo-api's TenantGuard), sign out
- Organizations, Tasks, and Team (members/RBAC) pages wired to ceo-api
  via a thin apiFetch wrapper that attaches the Supabase bearer token
- executive "paper & ink" design tokens (Tailwind) replacing defaults
- Dockerfile: declare NEXT_PUBLIC_* as build ARGs so Coolify's existing
  build-time env vars actually get inlined (was previously silently
  dropped, which would have crashed prerendering)
This commit is contained in:
valentinbvro 2026-07-28 21:16:17 +02:00
parent 349f4f713c
commit 883ff370cd
18 changed files with 1076 additions and 10 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ node_modules/
.next/
.env
*.log
next-env.d.ts

View file

@ -3,6 +3,15 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps --include=dev
COPY . .
# NEXT_PUBLIC_* sunt inlinite in bundle la build time, nu citite la runtime;
# Coolify le are configurate ca build-time variables (is_buildtime=true) si le
# injecteaza automat ca --build-arg pentru un Dockerfile care le declara ARG.
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
FROM node:22-alpine

View file

@ -0,0 +1,8 @@
import { DashboardShell } from '../../components/dashboard-shell';
// Tot ce e sub /dashboard depinde de sesiunea/tenantul curent -- niciodata prerandat static.
export const dynamic = 'force-dynamic';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return <DashboardShell>{children}</DashboardShell>;
}

View file

@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { apiFetch, type TenantMember } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
const addMemberSchema = z.object({
email: z.string().email('Adresă de email invalidă'),
role: z.enum(['admin', 'member']),
});
type AddMemberForm = z.infer<typeof addMemberSchema>;
export default function MembersPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const canManage = activeTenant?.role === 'owner' || activeTenant?.role === 'admin';
const queryClient = useQueryClient();
const [isFormOpen, setIsFormOpen] = useState(false);
const { data: members = [], isLoading } = useQuery({
queryKey: ['tenant-members', tenantId],
queryFn: () => apiFetch<TenantMember[]>('/v1/tenants/current/members', { tenantId }),
enabled: Boolean(tenantId),
});
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<AddMemberForm>({ resolver: zodResolver(addMemberSchema), defaultValues: { role: 'member' } });
const addMember = useMutation({
mutationFn: (values: AddMemberForm) =>
apiFetch<TenantMember>('/v1/tenants/current/members', { method: 'POST', tenantId, body: values }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['tenant-members', tenantId] });
reset();
setIsFormOpen(false);
},
});
const removeMember = useMutation({
mutationFn: (userId: string) =>
apiFetch<{ removed: boolean }>(`/v1/tenants/current/members/${userId}`, { method: 'DELETE', tenantId }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['tenant-members', tenantId] });
},
});
return (
<div className="max-w-2xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Echipă</h1>
<p className="text-sm text-ink-faint">Cine are acces la acest workspace.</p>
</div>
{canManage && (
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((open) => !open)}>
{isFormOpen ? 'Anulează' : 'Invită membru'}
</button>
)}
</div>
{isFormOpen && (
<form
onSubmit={handleSubmit((values) => addMember.mutate(values))}
noValidate
className="card mb-6 space-y-4 p-5"
>
<div>
<label className="label" htmlFor="member-email">
Email
</label>
<input id="member-email" type="email" className="field" {...register('email')} />
{errors.email && <p className="mt-1 text-xs text-signal-danger">{errors.email.message}</p>}
<p className="mt-1 text-xs text-ink-faint">Persoana trebuie aibă deja un cont creat.</p>
</div>
<div>
<label className="label" htmlFor="member-role">
Rol
</label>
<select id="member-role" className="field" {...register('role')}>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
{addMember.isError && (
<p className="text-sm text-signal-danger">
{addMember.error instanceof Error ? addMember.error.message : 'Eroare'}
</p>
)}
<button type="submit" disabled={isSubmitting || addMember.isPending} className="btn-primary">
Adaugă
</button>
</form>
)}
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
<ul className="space-y-2">
{members.map((member) => (
<li key={member.userId} className="card flex items-center justify-between p-4">
<div>
<p className="text-sm font-medium text-ink">{member.email ?? member.userId}</p>
<p className="text-xs capitalize text-ink-faint">{member.role}</p>
</div>
{canManage && member.role !== 'owner' && (
<button
type="button"
onClick={() => removeMember.mutate(member.userId)}
className="text-xs font-medium text-signal-danger hover:underline"
>
Elimină
</button>
)}
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { apiFetch, type Organization } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
const createOrganizationSchema = z.object({
name: z.string().min(1, 'Numele este obligatoriu').max(250),
domain: z.string().max(253).optional().or(z.literal('')),
});
type CreateOrganizationForm = z.infer<typeof createOrganizationSchema>;
export default function OrganizationsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const queryClient = useQueryClient();
const [isFormOpen, setIsFormOpen] = useState(false);
const { data: organizations = [], isLoading } = useQuery({
queryKey: ['organizations', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
enabled: Boolean(tenantId),
});
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateOrganizationForm>({ resolver: zodResolver(createOrganizationSchema) });
const createOrganization = useMutation({
mutationFn: (values: CreateOrganizationForm) =>
apiFetch<Organization>('/v1/organizations', {
method: 'POST',
tenantId,
body: { name: values.name, domain: values.domain || undefined },
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', tenantId] });
reset();
setIsFormOpen(false);
},
});
return (
<div className="max-w-3xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Companii</h1>
<p className="text-sm text-ink-faint">Companiile pe care le gestionezi în acest workspace.</p>
</div>
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((open) => !open)}>
{isFormOpen ? 'Anulează' : 'Adaugă companie'}
</button>
</div>
{isFormOpen && (
<form
onSubmit={handleSubmit((values) => createOrganization.mutate(values))}
noValidate
className="card mb-6 space-y-4 p-5"
>
<div>
<label className="label" htmlFor="org-name">
Nume
</label>
<input id="org-name" type="text" className="field" {...register('name')} />
{errors.name && <p className="mt-1 text-xs text-signal-danger">{errors.name.message}</p>}
</div>
<div>
<label className="label" htmlFor="org-domain">
Domeniu (opțional)
</label>
<input id="org-domain" type="text" placeholder="exemplu.ro" className="field" {...register('domain')} />
</div>
{createOrganization.isError && (
<p className="text-sm text-signal-danger">
{createOrganization.error instanceof Error ? createOrganization.error.message : 'Eroare'}
</p>
)}
<button type="submit" disabled={isSubmitting || createOrganization.isPending} className="btn-primary">
Salvează
</button>
</form>
)}
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
{!isLoading && organizations.length === 0 && (
<p className="card p-6 text-sm text-ink-faint">Nicio companie încă. Adaugă prima mai sus.</p>
)}
<ul className="space-y-2">
{organizations.map((organization) => (
<li key={organization.id} className="card flex items-center justify-between p-4">
<div>
<p className="text-sm font-medium text-ink">{organization.name}</p>
{organization.domain && <p className="text-xs text-ink-faint">{organization.domain}</p>}
</div>
{organization.country && (
<span className="rounded-full bg-paper-sunken px-2.5 py-1 text-xs text-ink-soft">
{organization.country}
</span>
)}
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,43 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { apiFetch, type Organization, type Task } from '../../lib/api';
import { useSession } from '../../components/session-provider';
export default function OverviewPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: organizations = [] } = useQuery({
queryKey: ['organizations', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
enabled: Boolean(tenantId),
});
const { data: openTasks = [] } = useQuery({
queryKey: ['tasks', tenantId, 'open'],
queryFn: () => apiFetch<Task[]>('/v1/tasks?status=open', { tenantId }),
enabled: Boolean(tenantId),
});
return (
<div className="max-w-4xl">
<h1 className="mb-1 font-display text-2xl font-semibold text-ink">
Bine ai revenit{activeTenant ? `, ${activeTenant.tenantName}` : ''}
</h1>
<p className="mb-8 text-sm text-ink-faint">Prezentarea generală a workspace-ului activ.</p>
<div className="grid grid-cols-2 gap-4">
<Link href="/dashboard/organizations" className="card block p-5 transition-shadow hover:shadow-overlay">
<p className="label">Companii</p>
<p className="font-display text-3xl font-semibold text-ink">{organizations.length}</p>
</Link>
<Link href="/dashboard/tasks" className="card block p-5 transition-shadow hover:shadow-overlay">
<p className="label">Sarcini deschise</p>
<p className="font-display text-3xl font-semibold text-ink">{openTasks.length}</p>
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { apiFetch, type Task, type TaskStatus } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
const createTaskSchema = z.object({
title: z.string().min(1, 'Titlul este obligatoriu').max(500),
});
type CreateTaskForm = z.infer<typeof createTaskSchema>;
const STATUS_LABELS: Record<TaskStatus, string> = {
open: 'Deschis',
in_progress: 'În lucru',
blocked: 'Blocat',
done: 'Finalizat',
cancelled: 'Anulat',
};
export default function TasksPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const queryClient = useQueryClient();
const [isFormOpen, setIsFormOpen] = useState(false);
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['tasks', tenantId, 'all'],
queryFn: () => apiFetch<Task[]>('/v1/tasks', { tenantId }),
enabled: Boolean(tenantId),
});
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateTaskForm>({ resolver: zodResolver(createTaskSchema) });
const createTask = useMutation({
mutationFn: (values: CreateTaskForm) =>
apiFetch<Task>('/v1/tasks', { method: 'POST', tenantId, body: values }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['tasks', tenantId] });
reset();
setIsFormOpen(false);
},
});
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: TaskStatus }) =>
apiFetch<Task>(`/v1/tasks/${id}`, { method: 'PATCH', tenantId, body: { status } }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['tasks', tenantId] });
},
});
return (
<div className="max-w-3xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Sarcini</h1>
<p className="text-sm text-ink-faint">Sarcinile tale și termenele asociate.</p>
</div>
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((open) => !open)}>
{isFormOpen ? 'Anulează' : 'Sarcină nouă'}
</button>
</div>
{isFormOpen && (
<form
onSubmit={handleSubmit((values) => createTask.mutate(values))}
noValidate
className="card mb-6 space-y-4 p-5"
>
<div>
<label className="label" htmlFor="task-title">
Titlu
</label>
<input id="task-title" type="text" className="field" {...register('title')} />
{errors.title && <p className="mt-1 text-xs text-signal-danger">{errors.title.message}</p>}
</div>
{createTask.isError && (
<p className="text-sm text-signal-danger">
{createTask.error instanceof Error ? createTask.error.message : 'Eroare'}
</p>
)}
<button type="submit" disabled={isSubmitting || createTask.isPending} className="btn-primary">
Salvează
</button>
</form>
)}
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
{!isLoading && tasks.length === 0 && (
<p className="card p-6 text-sm text-ink-faint">Nicio sarcină încă. Adaugă prima mai sus.</p>
)}
<ul className="space-y-2">
{tasks.map((task) => (
<li key={task.id} className="card flex items-center justify-between p-4">
<div>
<p className={`text-sm font-medium ${task.status === 'done' ? 'text-ink-faint line-through' : 'text-ink'}`}>
{task.title}
</p>
{task.dueAt && <p className="text-xs text-ink-faint">Termen: {new Date(task.dueAt).toLocaleDateString('ro-RO')}</p>}
</div>
<select
aria-label={`Status pentru ${task.title}`}
className="rounded-lg border border-ink-line bg-paper-raised px-2.5 py-1.5 text-xs font-medium text-ink-soft
focus:border-bronze focus:outline-none focus:ring-2 focus:ring-bronze/20"
value={task.status}
onChange={(event) =>
updateStatus.mutate({ id: task.id, status: event.target.value as TaskStatus })
}
>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</li>
))}
</ul>
</div>
);
}

View file

@ -1,3 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-paper text-ink antialiased;
}
::selection {
@apply bg-bronze-wash text-bronze-deep;
}
}
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center gap-2 rounded-lg bg-ink px-4 py-2.5 text-sm font-medium text-paper
transition-colors hover:bg-ink-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-bronze disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-ink-line bg-paper-raised px-4 py-2.5
text-sm font-medium text-ink-soft transition-colors hover:border-ink-faint hover:text-ink
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-bronze;
}
.field {
@apply w-full rounded-lg border border-ink-line bg-paper-raised px-3.5 py-2.5 text-sm text-ink
placeholder:text-ink-faint focus:border-bronze focus:outline-none focus:ring-2 focus:ring-bronze/20;
}
.card {
@apply rounded-xl border border-ink-line bg-paper-raised shadow-card;
}
.label {
@apply mb-1.5 block text-xs font-medium uppercase tracking-wide text-ink-faint;
}
}

View file

@ -1,16 +1,24 @@
import type { Metadata } from 'next';
import { Fraunces, Inter } from 'next/font/google';
import { Providers } from './providers';
import './globals.css';
const display = Fraunces({
subsets: ['latin'],
variable: '--font-display',
weight: ['500', '600'],
});
const sans = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const metadata: Metadata = {
title: 'CEO OS',
description: 'Sistem complex pentru antreprenori',
description: 'Personal & Executive Intelligence Operating System',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ro">
<body>
<html lang="ro" className={`${display.variable} ${sans.variable}`}>
<body className="font-sans">
<Providers>{children}</Providers>
</body>
</html>

128
src/app/login/page.tsx Normal file
View file

@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { supabase } from '../../lib/supabase';
export const dynamic = 'force-dynamic';
const credentialsSchema = z.object({
email: z.string().email('Adresă de email invalidă'),
password: z.string().min(8, 'Minimum 8 caractere'),
});
type Credentials = z.infer<typeof credentialsSchema>;
type Mode = 'sign-in' | 'sign-up';
export default function LoginPage() {
const router = useRouter();
const [mode, setMode] = useState<Mode>('sign-in');
const [formError, setFormError] = useState<string | null>(null);
const [confirmationSent, setConfirmationSent] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<Credentials>({ resolver: zodResolver(credentialsSchema) });
const onSubmit = async (values: Credentials) => {
setFormError(null);
setConfirmationSent(false);
if (mode === 'sign-up') {
const { error, data } = await supabase.auth.signUp({
email: values.email,
password: values.password,
});
if (error) {
setFormError(error.message);
return;
}
if (!data.session) {
setConfirmationSent(true);
return;
}
router.push('/onboarding');
return;
}
const { error } = await supabase.auth.signInWithPassword(values);
if (error) {
setFormError(error.message);
return;
}
router.push('/dashboard');
};
return (
<main className="flex min-h-screen items-center justify-center bg-paper px-4">
<div className="w-full max-w-sm">
<h1 className="mb-1 font-display text-2xl font-semibold text-ink">CEO OS</h1>
<p className="mb-8 text-sm text-ink-faint">
{mode === 'sign-in' ? 'Autentifică-te în contul tău' : 'Creează un cont nou'}
</p>
<div className="card p-6">
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label className="label" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
className="field"
{...register('email')}
/>
{errors.email && <p className="mt-1 text-xs text-signal-danger">{errors.email.message}</p>}
</div>
<div>
<label className="label" htmlFor="password">
Parolă
</label>
<input
id="password"
type="password"
autoComplete={mode === 'sign-in' ? 'current-password' : 'new-password'}
className="field"
{...register('password')}
/>
{errors.password && (
<p className="mt-1 text-xs text-signal-danger">{errors.password.message}</p>
)}
</div>
{formError && <p className="text-sm text-signal-danger">{formError}</p>}
{confirmationSent && (
<p className="text-sm text-signal-ok">
Cont creat. Verifică emailul pentru confirmare, apoi autentifică-te.
</p>
)}
<button type="submit" disabled={isSubmitting} className="btn-primary w-full">
{mode === 'sign-in' ? 'Autentificare' : 'Creează cont'}
</button>
</form>
</div>
<button
type="button"
onClick={() => {
setMode(mode === 'sign-in' ? 'sign-up' : 'sign-in');
setFormError(null);
setConfirmationSent(false);
}}
className="mt-4 w-full text-center text-sm text-ink-faint hover:text-ink"
>
{mode === 'sign-in' ? 'Nu ai cont? Creează unul' : 'Ai deja cont? Autentifică-te'}
</button>
</div>
</main>
);
}

View file

@ -0,0 +1,94 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { apiFetch, ACTIVE_TENANT_STORAGE_KEY, type TenantMembership } from '../../lib/api';
import { useSession } from '../../components/session-provider';
export const dynamic = 'force-dynamic';
const createTenantSchema = z.object({
name: z.string().min(2, 'Minimum 2 caractere').max(120),
});
type CreateTenantForm = z.infer<typeof createTenantSchema>;
export default function OnboardingPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { authSession, authLoading, me, meLoading } = useSession();
useEffect(() => {
if (authLoading) return;
if (!authSession) {
router.replace('/login');
return;
}
if (!meLoading && me && me.tenants.length > 0) {
router.replace('/dashboard');
}
}, [authLoading, authSession, me, meLoading, router]);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateTenantForm>({ resolver: zodResolver(createTenantSchema) });
const createTenant = useMutation({
mutationFn: (values: CreateTenantForm) =>
apiFetch<TenantMembership>('/v1/tenants', { method: 'POST', body: values }),
onSuccess: async (tenant) => {
window.localStorage.setItem(ACTIVE_TENANT_STORAGE_KEY, tenant.tenantId);
await queryClient.invalidateQueries({ queryKey: ['me'] });
router.replace('/dashboard');
},
});
return (
<main className="flex min-h-screen items-center justify-center bg-paper px-4">
<div className="w-full max-w-md">
<h1 className="mb-1 font-display text-2xl font-semibold text-ink">Bine ai venit</h1>
<p className="mb-8 text-sm text-ink-faint">
un nume companiei sau spațiului tău de lucru. Îl poți schimba oricând.
</p>
<div className="card p-6">
<form
onSubmit={handleSubmit((values) => createTenant.mutate(values))}
noValidate
className="space-y-4"
>
<div>
<label className="label" htmlFor="name">
Numele companiei / workspace-ului
</label>
<input
id="name"
type="text"
autoFocus
placeholder="ex. Rotaru Consulting SRL"
className="field"
{...register('name')}
/>
{errors.name && <p className="mt-1 text-xs text-signal-danger">{errors.name.message}</p>}
</div>
{createTenant.isError && (
<p className="text-sm text-signal-danger">
{createTenant.error instanceof Error ? createTenant.error.message : 'A apărut o eroare'}
</p>
)}
<button type="submit" disabled={isSubmitting || createTenant.isPending} className="btn-primary w-full">
Continuă
</button>
</form>
</div>
</div>
</main>
);
}

View file

@ -1,10 +1,34 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from '../components/session-provider';
// Depinde de sesiunea auth curenta -- niciodata prerandat static/cache-uit.
export const dynamic = 'force-dynamic';
export default function HomePage() {
const router = useRouter();
const { authSession, authLoading, me, meLoading } = useSession();
useEffect(() => {
if (authLoading || (authSession && meLoading)) {
return;
}
if (!authSession) {
router.replace('/login');
return;
}
if (me && me.tenants.length === 0) {
router.replace('/onboarding');
return;
}
router.replace('/dashboard');
}, [authLoading, authSession, me, meLoading, router]);
return (
<main className="max-w-3xl mx-auto p-8">
<h1 className="text-3xl font-bold">CEO OS</h1>
<p className="text-gray-500 mt-2">
Scaffold initial conectat la ceo-api si Supabase, stilizat cu Tailwind.
</p>
<main className="flex min-h-screen items-center justify-center bg-paper">
<p className="text-sm text-ink-faint">Se încarcă</p>
</main>
);
}

View file

@ -2,8 +2,13 @@
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SessionProvider } from '../components/session-provider';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>{children}</SessionProvider>
</QueryClientProvider>
);
}

View file

@ -0,0 +1,86 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useSession } from './session-provider';
import { TenantSwitcher } from './tenant-switcher';
const NAV_ITEMS = [
{ href: '/dashboard', label: 'Prezentare generală' },
{ href: '/dashboard/organizations', label: 'Companii' },
{ href: '/dashboard/tasks', label: 'Sarcini' },
{ href: '/dashboard/members', label: 'Echipă' },
];
/**
* Guard + shell pentru tot ce e sub /dashboard: redirecteaza catre /login sau
* /onboarding daca nu exista sesiune / tenant, altfel afiseaza sidebar +
* tenant switcher (blueprint 4: identity, tenancy si roluri sunt vizibile
* la nivel de UI, nu doar de API).
*/
export function DashboardShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { authSession, authLoading, me, meLoading, activeTenant, signOut } = useSession();
useEffect(() => {
if (authLoading) return;
if (!authSession) {
router.replace('/login');
return;
}
if (!meLoading && me && me.tenants.length === 0) {
router.replace('/onboarding');
}
}, [authLoading, authSession, me, meLoading, router]);
if (authLoading || meLoading || !activeTenant) {
return (
<div className="flex min-h-screen items-center justify-center bg-paper">
<p className="text-sm text-ink-faint">Se încarcă</p>
</div>
);
}
return (
<div className="flex min-h-screen bg-paper">
<aside className="flex w-60 shrink-0 flex-col border-r border-ink-line bg-paper-raised p-4">
<div className="mb-6 px-2">
<span className="font-display text-lg font-semibold text-ink">CEO OS</span>
</div>
<nav className="flex-1 space-y-1">
{NAV_ITEMS.map((item) => {
const isActive = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive ? 'bg-bronze-wash text-bronze-deep' : 'text-ink-soft hover:bg-paper-sunken hover:text-ink'
}`}
>
{item.label}
</Link>
);
})}
</nav>
<div className="border-t border-ink-line pt-3">
<p className="truncate px-2 text-xs text-ink-faint">{me?.email}</p>
<button type="button" onClick={() => void signOut()} className="mt-1 w-full px-2 py-1.5 text-left text-sm text-ink-faint hover:text-ink">
Deconectare
</button>
</div>
</aside>
<div className="flex flex-1 flex-col">
<header className="flex h-16 items-center justify-between border-b border-ink-line px-6">
<TenantSwitcher />
<span className="rounded-full bg-paper-sunken px-2.5 py-1 text-xs font-medium capitalize text-ink-soft">
{activeTenant.role}
</span>
</header>
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}

View file

@ -0,0 +1,85 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { Session } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
import { ACTIVE_TENANT_STORAGE_KEY, apiFetch, type MeResponse, type TenantMembership } from '../lib/api';
interface SessionContextValue {
authSession: Session | null;
authLoading: boolean;
me: MeResponse | null;
meLoading: boolean;
activeTenant: TenantMembership | null;
setActiveTenantId: (tenantId: string) => void;
signOut: () => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
export function SessionProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const queryClient = useQueryClient();
const [authSession, setAuthSession] = useState<Session | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [activeTenantId, setActiveTenantIdState] = useState<string | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setAuthSession(data.session);
setAuthLoading(false);
});
const { data: subscription } = supabase.auth.onAuthStateChange((_event, session) => {
setAuthSession(session);
});
setActiveTenantIdState(window.localStorage.getItem(ACTIVE_TENANT_STORAGE_KEY));
return () => subscription.subscription.unsubscribe();
}, []);
const { data: me = null, isLoading: meLoading } = useQuery({
queryKey: ['me', authSession?.user.id],
queryFn: () => apiFetch<MeResponse>('/v1/me'),
enabled: Boolean(authSession),
});
const activeTenant = useMemo(() => {
if (!me || me.tenants.length === 0) {
return null;
}
return me.tenants.find((tenant) => tenant.tenantId === activeTenantId) ?? me.tenants[0];
}, [me, activeTenantId]);
const setActiveTenantId = useCallback(
(tenantId: string) => {
window.localStorage.setItem(ACTIVE_TENANT_STORAGE_KEY, tenantId);
setActiveTenantIdState(tenantId);
// datele afisate apartin tenantului anterior — invalidam tot ce e tenant-scoped
queryClient.invalidateQueries();
},
[queryClient],
);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
window.localStorage.removeItem(ACTIVE_TENANT_STORAGE_KEY);
queryClient.clear();
router.push('/login');
}, [queryClient, router]);
const value = useMemo(
() => ({ authSession, authLoading, me, meLoading, activeTenant, setActiveTenantId, signOut }),
[authSession, authLoading, me, meLoading, activeTenant, setActiveTenantId, signOut],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSession(): SessionContextValue {
const context = useContext(SessionContext);
if (!context) {
throw new Error('useSession must be used inside SessionProvider');
}
return context;
}

View file

@ -0,0 +1,31 @@
'use client';
import { useSession } from './session-provider';
export function TenantSwitcher() {
const { me, activeTenant, setActiveTenantId } = useSession();
if (!me || me.tenants.length === 0 || !activeTenant) {
return null;
}
if (me.tenants.length === 1) {
return <span className="text-sm font-medium text-ink">{activeTenant.tenantName}</span>;
}
return (
<select
aria-label="Workspace activ"
className="rounded-lg border border-ink-line bg-paper-raised px-3 py-1.5 text-sm font-medium text-ink
focus:border-bronze focus:outline-none focus:ring-2 focus:ring-bronze/20"
value={activeTenant.tenantId}
onChange={(event) => setActiveTenantId(event.target.value)}
>
{me.tenants.map((tenant) => (
<option key={tenant.tenantId} value={tenant.tenantId}>
{tenant.tenantName}
</option>
))}
</select>
);
}

105
src/lib/api.ts Normal file
View file

@ -0,0 +1,105 @@
import { supabase } from './supabase';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001';
export const ACTIVE_TENANT_STORAGE_KEY = 'ceo-os.active-tenant';
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
}
}
interface ApiFetchOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
/** Rutele de bootstrap (/v1/me, /v1/tenants) nu trimit tenant header. */
tenantId?: string | null;
}
/**
* Toate apelurile catre ceo-api trec pe aici: Bearer din sesiunea Supabase,
* tenantul activ in x-tenant-id (niciodata in body/query TenantGuard pe
* server respinge exact acel pattern).
*/
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (!token) {
throw new ApiError(401, 'Not signed in');
}
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
if (options.tenantId) {
headers['x-tenant-id'] = options.tenantId;
}
const response = await fetch(`${API_URL}${path}`, {
method: options.method ?? 'GET',
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
let message = `Request failed (${response.status})`;
try {
const payload = (await response.json()) as { message?: string | string[] };
if (payload.message) {
message = Array.isArray(payload.message) ? payload.message.join('; ') : payload.message;
}
} catch {
// corpul nu e JSON — pastram mesajul generic
}
throw new ApiError(response.status, message);
}
return (await response.json()) as T;
}
// --- Tipuri aliniate la contractele ceo-api ---
export interface TenantMembership {
tenantId: string;
tenantName: string;
role: 'owner' | 'admin' | 'member';
}
export interface MeResponse {
userId: string;
email: string | null;
tenants: TenantMembership[];
}
export interface Organization {
id: string;
name: string;
legalName: string | null;
country: string | null;
registryId: string | null;
domain: string | null;
createdAt: string;
}
export type TaskStatus = 'open' | 'in_progress' | 'blocked' | 'done' | 'cancelled';
export interface Task {
id: string;
title: string;
priority: number;
dueAt: string | null;
status: TaskStatus;
createdAt: string;
}
export interface TenantMember {
userId: string;
email: string | null;
role: 'owner' | 'admin' | 'member';
createdAt: string;
}

View file

@ -1,7 +1,43 @@
import type { Config } from 'tailwindcss';
// Design tokens CEO OS — "executive paper & ink": suprafete calde, cerneala
// densa, un singur accent (bronz) folosit semantic pentru actiuni primare.
export default {
content: ['./src/**/*.{ts,tsx}'],
theme: { extend: {} },
theme: {
extend: {
colors: {
paper: {
DEFAULT: '#faf8f4',
raised: '#ffffff',
sunken: '#f1ede5',
},
ink: {
DEFAULT: '#191714',
soft: '#4a453d',
faint: '#8a8375',
line: '#e4ded2',
},
bronze: {
DEFAULT: '#9a6b2f',
deep: '#7c5322',
wash: '#f3e9da',
},
signal: {
ok: '#3d7a4e',
warn: '#b07a1e',
danger: '#a63d2f',
},
},
fontFamily: {
display: ['var(--font-display)', 'Georgia', 'serif'],
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
},
boxShadow: {
card: '0 1px 2px rgba(25,23,20,0.05), 0 4px 16px rgba(25,23,20,0.06)',
overlay: '0 8px 40px rgba(25,23,20,0.18)',
},
},
},
plugins: [],
} satisfies Config;