diff --git a/.gitignore b/.gitignore index b7cfe70..07d6b2a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .next/ .env *.log +next-env.d.ts diff --git a/Dockerfile b/Dockerfile index 9bef96e..9d8634c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..e553a3f --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -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 {children}; +} diff --git a/src/app/dashboard/members/page.tsx b/src/app/dashboard/members/page.tsx new file mode 100644 index 0000000..d215675 --- /dev/null +++ b/src/app/dashboard/members/page.tsx @@ -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; + +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('/v1/tenants/current/members', { tenantId }), + enabled: Boolean(tenantId), + }); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(addMemberSchema), defaultValues: { role: 'member' } }); + + const addMember = useMutation({ + mutationFn: (values: AddMemberForm) => + apiFetch('/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 ( +
+
+
+

Echipă

+

Cine are acces la acest workspace.

+
+ {canManage && ( + + )} +
+ + {isFormOpen && ( +
addMember.mutate(values))} + noValidate + className="card mb-6 space-y-4 p-5" + > +
+ + + {errors.email &&

{errors.email.message}

} +

Persoana trebuie să aibă deja un cont creat.

+
+
+ + +
+ {addMember.isError && ( +

+ {addMember.error instanceof Error ? addMember.error.message : 'Eroare'} +

+ )} + +
+ )} + + {isLoading &&

Se încarcă…

} + +
    + {members.map((member) => ( +
  • +
    +

    {member.email ?? member.userId}

    +

    {member.role}

    +
    + {canManage && member.role !== 'owner' && ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/src/app/dashboard/organizations/page.tsx b/src/app/dashboard/organizations/page.tsx new file mode 100644 index 0000000..f107319 --- /dev/null +++ b/src/app/dashboard/organizations/page.tsx @@ -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; + +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('/v1/organizations', { tenantId }), + enabled: Boolean(tenantId), + }); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(createOrganizationSchema) }); + + const createOrganization = useMutation({ + mutationFn: (values: CreateOrganizationForm) => + apiFetch('/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 ( +
+
+
+

Companii

+

Companiile pe care le gestionezi în acest workspace.

+
+ +
+ + {isFormOpen && ( +
createOrganization.mutate(values))} + noValidate + className="card mb-6 space-y-4 p-5" + > +
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + +
+ {createOrganization.isError && ( +

+ {createOrganization.error instanceof Error ? createOrganization.error.message : 'Eroare'} +

+ )} + +
+ )} + + {isLoading &&

Se încarcă…

} + {!isLoading && organizations.length === 0 && ( +

Nicio companie încă. Adaugă prima mai sus.

+ )} + +
    + {organizations.map((organization) => ( +
  • +
    +

    {organization.name}

    + {organization.domain &&

    {organization.domain}

    } +
    + {organization.country && ( + + {organization.country} + + )} +
  • + ))} +
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..b77512f --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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('/v1/organizations', { tenantId }), + enabled: Boolean(tenantId), + }); + + const { data: openTasks = [] } = useQuery({ + queryKey: ['tasks', tenantId, 'open'], + queryFn: () => apiFetch('/v1/tasks?status=open', { tenantId }), + enabled: Boolean(tenantId), + }); + + return ( +
+

+ Bine ai revenit{activeTenant ? `, ${activeTenant.tenantName}` : ''} +

+

Prezentarea generală a workspace-ului activ.

+ +
+ +

Companii

+

{organizations.length}

+ + +

Sarcini deschise

+

{openTasks.length}

+ +
+
+ ); +} diff --git a/src/app/dashboard/tasks/page.tsx b/src/app/dashboard/tasks/page.tsx new file mode 100644 index 0000000..c6c16ac --- /dev/null +++ b/src/app/dashboard/tasks/page.tsx @@ -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; + +const STATUS_LABELS: Record = { + 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('/v1/tasks', { tenantId }), + enabled: Boolean(tenantId), + }); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(createTaskSchema) }); + + const createTask = useMutation({ + mutationFn: (values: CreateTaskForm) => + apiFetch('/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(`/v1/tasks/${id}`, { method: 'PATCH', tenantId, body: { status } }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['tasks', tenantId] }); + }, + }); + + return ( +
+
+
+

Sarcini

+

Sarcinile tale și termenele asociate.

+
+ +
+ + {isFormOpen && ( +
createTask.mutate(values))} + noValidate + className="card mb-6 space-y-4 p-5" + > +
+ + + {errors.title &&

{errors.title.message}

} +
+ {createTask.isError && ( +

+ {createTask.error instanceof Error ? createTask.error.message : 'Eroare'} +

+ )} + +
+ )} + + {isLoading &&

Se încarcă…

} + {!isLoading && tasks.length === 0 && ( +

Nicio sarcină încă. Adaugă prima mai sus.

+ )} + +
    + {tasks.map((task) => ( +
  • +
    +

    + {task.title} +

    + {task.dueAt &&

    Termen: {new Date(task.dueAt).toLocaleDateString('ro-RO')}

    } +
    + +
  • + ))} +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index b5c61c9..f73d534 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7753352..38ef69e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - - + + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..d49447e --- /dev/null +++ b/src/app/login/page.tsx @@ -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; + +type Mode = 'sign-in' | 'sign-up'; + +export default function LoginPage() { + const router = useRouter(); + const [mode, setMode] = useState('sign-in'); + const [formError, setFormError] = useState(null); + const [confirmationSent, setConfirmationSent] = useState(false); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ 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 ( +
+
+

CEO OS

+

+ {mode === 'sign-in' ? 'Autentifică-te în contul tău' : 'Creează un cont nou'} +

+ +
+
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ + {formError &&

{formError}

} + {confirmationSent && ( +

+ Cont creat. Verifică emailul pentru confirmare, apoi autentifică-te. +

+ )} + + +
+
+ + +
+
+ ); +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx new file mode 100644 index 0000000..b1b279f --- /dev/null +++ b/src/app/onboarding/page.tsx @@ -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; + +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({ resolver: zodResolver(createTenantSchema) }); + + const createTenant = useMutation({ + mutationFn: (values: CreateTenantForm) => + apiFetch('/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 ( +
+
+

Bine ai venit

+

+ Dă un nume companiei sau spațiului tău de lucru. Îl poți schimba oricând. +

+ +
+
createTenant.mutate(values))} + noValidate + className="space-y-4" + > +
+ + + {errors.name &&

{errors.name.message}

} +
+ + {createTenant.isError && ( +

+ {createTenant.error instanceof Error ? createTenant.error.message : 'A apărut o eroare'} +

+ )} + + +
+
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 0ff7219..937089f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-

CEO OS

-

- Scaffold initial — conectat la ceo-api si Supabase, stilizat cu Tailwind. -

+
+

Se încarcă…

); } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index a2e609d..fe29e07 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -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 {children}; + return ( + + {children} + + ); } diff --git a/src/components/dashboard-shell.tsx b/src/components/dashboard-shell.tsx new file mode 100644 index 0000000..ebfe3fb --- /dev/null +++ b/src/components/dashboard-shell.tsx @@ -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 ( +
+

Se încarcă…

+
+ ); + } + + return ( +
+ +
+
+ + + {activeTenant.role} + +
+
{children}
+
+
+ ); +} diff --git a/src/components/session-provider.tsx b/src/components/session-provider.tsx new file mode 100644 index 0000000..8b654ce --- /dev/null +++ b/src/components/session-provider.tsx @@ -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; +} + +const SessionContext = createContext(null); + +export function SessionProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [authSession, setAuthSession] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + const [activeTenantId, setActiveTenantIdState] = useState(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('/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 {children}; +} + +export function useSession(): SessionContextValue { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSession must be used inside SessionProvider'); + } + return context; +} diff --git a/src/components/tenant-switcher.tsx b/src/components/tenant-switcher.tsx new file mode 100644 index 0000000..b563f82 --- /dev/null +++ b/src/components/tenant-switcher.tsx @@ -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 {activeTenant.tenantName}; + } + + return ( + + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..d2fb2bd --- /dev/null +++ b/src/lib/api.ts @@ -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(path: string, options: ApiFetchOptions = {}): Promise { + const { data } = await supabase.auth.getSession(); + const token = data.session?.access_token; + if (!token) { + throw new ApiError(401, 'Not signed in'); + } + + const headers: Record = { + 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; +} diff --git a/tailwind.config.ts b/tailwind.config.ts index a553a08..4db510d 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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;