feat(arayüz): API istemcisi, TanStack Query, ts-rs tip üretimi ve oturum durumu
- Backend: ts-rs ile DTO'lardan TypeScript tipleri (frontend/src/types, npm run types) - Hata ve health yanıtları tipli yapılara çevrildi - lib/api.ts: tek API istemcisi, Türkçe ApiError, ağ hatası mesajı - lib/query.ts: QueryClient (4xx tekrar denenmez, 401 → oturum kapanır) - lib/auth.ts: useMe, useLogin, useRegister, useLogout - Vitest: 7 API istemcisi testi (toplam 29) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+73
-17
@@ -1,27 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { LogIn, LogOut, Plus } from 'lucide-react'
|
||||
import { Logo } from '@/components/logo'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate, formatMoney, formatQuantity, toIsoDate } from '@/lib/format'
|
||||
import { api } from '@/lib/api'
|
||||
import { useLogin, useLogout, useMe } from '@/lib/auth'
|
||||
import { GROUPS } from '@/lib/groups'
|
||||
|
||||
type Health = { status: string; db: string; version: string }
|
||||
import { queryKeys } from '@/lib/query'
|
||||
import type { HealthResponse } from '@/types/HealthResponse'
|
||||
|
||||
// Faz 2 – Adım 1 geçici ekranı: tasarım sistemi önizlemesi.
|
||||
// Adım 4'te gerçek layout ve sayfalarla değiştirilecek.
|
||||
export default function App() {
|
||||
const [health, setHealth] = useState<Health | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const health = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => api.get<HealthResponse>('/health'),
|
||||
})
|
||||
const [today] = useState(() => toIsoDate(new Date()))
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/health')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then(setHealth)
|
||||
.catch(() => setError(true))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
@@ -35,15 +33,17 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
<p className="text-sm">
|
||||
{error && <span className="text-expense">● Backend'e ulaşılamadı</span>}
|
||||
{!error && !health && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
|
||||
{health && (
|
||||
{health.isError && <span className="text-expense">● {health.error.message}</span>}
|
||||
{health.isPending && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
|
||||
{health.data && (
|
||||
<span className="text-income">
|
||||
● Backend çalışıyor (v{health.version}, DB: {health.db})
|
||||
● Backend çalışıyor (v{health.data.version}, DB: {health.data.db})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<SessionPanel />
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">Ana gruplar</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
@@ -102,3 +102,59 @@ export default function App() {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/** Adım 3 geçici paneli: oturum akışını uçtan uca dener. Adım 5'te gerçek giriş ekranı gelir. */
|
||||
function SessionPanel() {
|
||||
const me = useMe()
|
||||
const login = useLogin()
|
||||
const logout = useLogout()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
login.mutate({ email, password, remember_me: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<h2 className="text-xl font-semibold">Oturum (geçici test paneli)</h2>
|
||||
{me.isPending && <p className="text-muted-foreground">Oturum kontrol ediliyor…</p>}
|
||||
{me.data && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p>
|
||||
👋 <strong>{me.data.user.name}</strong> · {me.data.household.name} ·{' '}
|
||||
<span className="text-muted-foreground">{me.data.user.role === 'owner' ? 'Hane sahibi' : 'Üye'}</span>
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => logout.mutate()} disabled={logout.isPending}>
|
||||
<LogOut /> Çıkış
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{me.data === null && (
|
||||
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="h-11 rounded-md border bg-background px-3 sm:flex-1"
|
||||
type="email"
|
||||
placeholder="E-posta"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-md border bg-background px-3 sm:flex-1"
|
||||
type="password"
|
||||
placeholder="Şifre"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={login.isPending}>
|
||||
<LogIn /> Giriş
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{login.isError && <p className="text-sm text-expense">{login.error.message}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, ApiError } from './api'
|
||||
|
||||
function mockFetch(status: number, body?: unknown) {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
return globalThis.fetch as ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('api', () => {
|
||||
it('başarılı JSON yanıtı döner, /api önekini ekler', async () => {
|
||||
const f = mockFetch(200, { status: 'ok' })
|
||||
await expect(api.get('/health')).resolves.toEqual({ status: 'ok' })
|
||||
expect(f).toHaveBeenCalledWith('/api/health', expect.objectContaining({ method: 'GET' }))
|
||||
})
|
||||
|
||||
it('gövdeyi JSON olarak gönderir', async () => {
|
||||
const f = mockFetch(201, { id: '1' })
|
||||
await api.post('/categories', { name: 'Market' })
|
||||
const init = f.mock.calls[0][1] as RequestInit
|
||||
expect(init.body).toBe('{"name":"Market"}')
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
})
|
||||
|
||||
it('204 → undefined', async () => {
|
||||
mockFetch(204)
|
||||
await expect(api.post('/auth/logout')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('backend hatasını Türkçe mesajıyla ApiError yapar', async () => {
|
||||
mockFetch(409, { error: { code: 'conflict', message: 'Bu e-posta adresi zaten kayıtlı' } })
|
||||
const err = await api.post('/auth/register', {}).catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect(err).toMatchObject({
|
||||
status: 409,
|
||||
code: 'conflict',
|
||||
message: 'Bu e-posta adresi zaten kayıtlı',
|
||||
})
|
||||
})
|
||||
|
||||
it('401 → isUnauthorized', async () => {
|
||||
mockFetch(401, { error: { code: 'unauthorized', message: 'Oturum açmanız gerekiyor' } })
|
||||
const err = (await api.get('/auth/me').catch((e: unknown) => e)) as ApiError
|
||||
expect(err.isUnauthorized).toBe(true)
|
||||
})
|
||||
|
||||
it('JSON olmayan hata (ör. proxy 502) → genel Türkçe mesaj', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('Bad Gateway', { status: 502 }))
|
||||
await expect(api.get('/health')).rejects.toMatchObject({
|
||||
status: 502,
|
||||
message: 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.',
|
||||
})
|
||||
})
|
||||
|
||||
it('ağ hatası → code "network"', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
await expect(api.get('/health')).rejects.toMatchObject({ status: 0, code: 'network' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Backend ile konuşan tek yer. Tüm istekler `/api` altına gider
|
||||
* (geliştirmede Vite proxy, canlıda aynı sunucu → CORS yok).
|
||||
*
|
||||
* Oturum, httpOnly cookie ile taşınır: JavaScript token'ı görmez, tarayıcı kendisi gönderir.
|
||||
*/
|
||||
import type { ApiErrorBody } from '@/types/ApiErrorBody'
|
||||
|
||||
/** Backend'in döndürdüğü hata, Türkçe mesajıyla. Arayüzde doğrudan `error.message` gösterilir. */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
/** `unauthorized`, `bad_request`, `conflict`, `not_found`, `network`, ... */
|
||||
readonly code: string
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
|
||||
get isUnauthorized() {
|
||||
return this.status === 401
|
||||
}
|
||||
}
|
||||
|
||||
const NETWORK_MESSAGE = 'Sunucuya ulaşılamadı. İnternet bağlantınızı kontrol edip tekrar deneyin.'
|
||||
const UNKNOWN_MESSAGE = 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.'
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
async function request<T>(method: Method, path: string, body?: unknown): Promise<T> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(0, 'network', NETWORK_MESSAGE)
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const data: unknown = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = (data as Partial<ApiErrorBody> | null)?.error
|
||||
throw new ApiError(res.status, detail?.code ?? 'unknown', detail?.message ?? UNKNOWN_MESSAGE)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
|
||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
|
||||
delete: <T = void>(path: string) => request<T>('DELETE', path),
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Oturum durumu ve giriş / kayıt / çıkış işlemleri.
|
||||
*
|
||||
* "Kim giriş yapmış?" bilgisinin tek kaynağı `useMe()`:
|
||||
* - `undefined` → henüz kontrol ediliyor
|
||||
* - `null` → oturum yok
|
||||
* - `MeResponse`→ giriş yapılmış
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { LoginRequest } from '@/types/LoginRequest'
|
||||
import type { MeResponse } from '@/types/MeResponse'
|
||||
import type { RegisterRequest } from '@/types/RegisterRequest'
|
||||
import { api, ApiError } from './api'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
async function fetchMe(): Promise<MeResponse | null> {
|
||||
try {
|
||||
return await api.get<MeResponse>('/auth/me')
|
||||
} catch (e) {
|
||||
// Oturum yoksa bu bir hata değil, bilinen bir durum
|
||||
if (e instanceof ApiError && e.isUnauthorized) return null
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.me,
|
||||
queryFn: fetchMe,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: LoginRequest) => api.post<MeResponse>('/auth/login', body),
|
||||
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: RegisterRequest) => api.post<MeResponse>('/auth/register', body),
|
||||
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => api.post<void>('/auth/logout'),
|
||||
onSettled: () => {
|
||||
// Başka kullanıcının verisi ekranda/önbellekte kalmasın
|
||||
qc.clear()
|
||||
qc.setQueryData(queryKeys.me, null)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
* Renk asla tek başına anlam taşımaz: her zaman emoji/ikon + etiketle birlikte kullanılır.
|
||||
*/
|
||||
import { BookOpen, House, ShoppingBag, Zap, type LucideIcon } from 'lucide-react'
|
||||
import type { Group } from '@/types/Group'
|
||||
|
||||
export type GroupCode = 'needs' | 'wants' | 'culture' | 'unexpected'
|
||||
/** Backend'den üretilen tip (ts-rs): `"needs" | "wants" | "culture" | "unexpected"` */
|
||||
export type GroupCode = Group
|
||||
|
||||
export type GroupMeta = {
|
||||
code: GroupCode
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* TanStack Query ayarları: sunucu verisinin önbelleği, yeniden deneme, yenileme.
|
||||
*/
|
||||
import { QueryCache, QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError } from './api'
|
||||
|
||||
/** Önbellek anahtarları tek yerde (yazım hatası olmasın) */
|
||||
export const queryKeys = {
|
||||
me: ['me'] as const,
|
||||
health: ['health'] as const,
|
||||
household: ['household'] as const,
|
||||
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
|
||||
paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const,
|
||||
}
|
||||
|
||||
/** 4xx hataları tekrar denemenin anlamı yok (yanlış istek / yetki); ağ ve 5xx için 2 kez dene. */
|
||||
function shouldRetry(failureCount: number, error: unknown) {
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false
|
||||
return failureCount < 2
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
// Herhangi bir istek 401 alırsa oturum bitmiş demektir → "me" boşaltılır,
|
||||
// korumalı sayfalar (Adım 4) kullanıcıyı giriş ekranına yönlendirir.
|
||||
if (error instanceof ApiError && error.isUnauthorized) {
|
||||
queryClient.setQueryData(queryKeys.me, null)
|
||||
}
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: shouldRetry,
|
||||
staleTime: 30_000, // 30 sn içinde aynı veri tekrar istenmez
|
||||
refetchOnWindowFocus: true, // sekmeye dönünce güncel veri (ör. eşin eklediği harcama)
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,13 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { queryClient } from '@/lib/query'
|
||||
import { ThemeProvider } from '@/lib/theme'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ApiErrorDetail } from "./ApiErrorDetail";
|
||||
|
||||
/**
|
||||
* Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }`
|
||||
*/
|
||||
export type ApiErrorBody = { error: ApiErrorDetail, };
|
||||
@@ -0,0 +1,11 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ApiErrorDetail = {
|
||||
/**
|
||||
* Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ...
|
||||
*/
|
||||
code: string,
|
||||
/**
|
||||
* Kullanıcıya gösterilecek Türkçe mesaj
|
||||
*/
|
||||
message: string, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Group } from "./Group";
|
||||
|
||||
export type CatalogCreateRequest = { group?: Group, name: string, emoji?: string, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Group } from "./Group";
|
||||
|
||||
export type CatalogItem = { id: string, group?: Group, name: string, emoji: string | null, sort_order: number, is_active: boolean, version: number, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Group } from "./Group";
|
||||
|
||||
export type CatalogOrderRequest = { group?: Group, ids: Array<string>, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type CatalogUpdateRequest = { name?: string, emoji?: string, is_active?: boolean, version?: number, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Group = "needs" | "wants" | "culture" | "unexpected";
|
||||
@@ -0,0 +1,11 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HealthResponse = {
|
||||
/**
|
||||
* `ok` veya `degraded`
|
||||
*/
|
||||
status: string,
|
||||
/**
|
||||
* `ok` veya `error`
|
||||
*/
|
||||
db: string, version: string, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Member } from "./Member";
|
||||
|
||||
export type Household = { id: string, name: string, members: Array<Member>, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HouseholdSummary = { id: string, name: string, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Invite = { id: string, code: string, expires_at: string, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type LoginRequest = { email: string, password: string, remember_me: boolean, };
|
||||
@@ -0,0 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { HouseholdSummary } from "./HouseholdSummary";
|
||||
import type { User } from "./User";
|
||||
|
||||
export type MeResponse = { user: User, household: HouseholdSummary, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Role } from "./Role";
|
||||
|
||||
export type Member = { id: string, name: string, email: string, role: Role, };
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type RegisterRequest = { name: string, email: string, password: string, household_name?: string,
|
||||
/**
|
||||
* Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır.
|
||||
*/
|
||||
invite_code?: string, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Role = "owner" | "member";
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Role } from "./Role";
|
||||
|
||||
export type User = { id: string, name: string, email: string, role: Role, };
|
||||
Reference in New Issue
Block a user