Initial Mobile
This commit is contained in:
2
mobile/src/config/env.ts
Normal file
2
mobile/src/config/env.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const API_BASE_URL =
|
||||
process.env.EXPO_PUBLIC_API_BASE?.replace(/\/$/, '') || 'http://localhost:3100';
|
||||
260
mobile/src/lib/api.ts
Normal file
260
mobile/src/lib/api.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { API_BASE_URL } from '@/src/config/env';
|
||||
|
||||
export type Tenant = {
|
||||
id: number;
|
||||
name: string;
|
||||
short?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskStatus = 'Offen' | 'In Bearbeitung' | 'Abgeschlossen';
|
||||
|
||||
export type Task = {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
categorie?: string | null;
|
||||
userId?: string | null;
|
||||
user_id?: string | null;
|
||||
profile?: string | null;
|
||||
archived?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TenantProfile = {
|
||||
id?: number | string;
|
||||
user_id?: string;
|
||||
full_name?: string;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type StaffTimeSpan = {
|
||||
id: string | null;
|
||||
eventIds: string[];
|
||||
state: string;
|
||||
started_at: string;
|
||||
stopped_at: string | null;
|
||||
duration_minutes: number;
|
||||
user_id: string | null;
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type MeResponse = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
must_change_password?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
tenants: Tenant[];
|
||||
activeTenant: number | string | null;
|
||||
profile: Record<string, unknown> | null;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
token?: string | null;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${API_BASE_URL}${normalizedPath}`;
|
||||
}
|
||||
|
||||
async function parseJson(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await fetch(buildUrl(path), {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const payload = await parseJson(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload as { message?: string; error?: string } | null)?.message ||
|
||||
(payload as { message?: string; error?: string } | null)?.error ||
|
||||
`Request failed (${response.status}) for ${path}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> {
|
||||
return apiRequest<{ status: string; [key: string]: unknown }>('/health');
|
||||
}
|
||||
|
||||
export async function loginWithEmailPassword(email: string, password: string): Promise<string> {
|
||||
const payload = await apiRequest<{ token?: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
});
|
||||
|
||||
if (!payload?.token) {
|
||||
throw new Error('Login did not return a token.');
|
||||
}
|
||||
|
||||
return payload.token;
|
||||
}
|
||||
|
||||
export async function fetchMe(token: string): Promise<MeResponse> {
|
||||
return apiRequest<MeResponse>('/api/me', {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> {
|
||||
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { tenant_id: String(tenantId) },
|
||||
});
|
||||
|
||||
if (!payload?.token) {
|
||||
throw new Error('Tenant switch did not return a token.');
|
||||
}
|
||||
|
||||
return payload.token;
|
||||
}
|
||||
|
||||
export async function fetchTasks(token: string): Promise<Task[]> {
|
||||
const tasks = await apiRequest<Task[]>('/api/resource/tasks', { token });
|
||||
return (tasks || []).filter((task) => !task.archived);
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
token: string,
|
||||
payload: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
categorie?: TaskStatus;
|
||||
userId?: string | null;
|
||||
}
|
||||
): Promise<Task> {
|
||||
return apiRequest<Task>('/api/resource/tasks', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTask(token: string, taskId: number, payload: Partial<Task>): Promise<Task> {
|
||||
return apiRequest<Task>(`/api/resource/tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTenantProfiles(token: string): Promise<TenantProfile[]> {
|
||||
const response = await apiRequest<{ data?: TenantProfile[] }>('/api/tenant/profiles', { token });
|
||||
return response?.data || [];
|
||||
}
|
||||
|
||||
export async function fetchStaffTimeSpans(
|
||||
token: string,
|
||||
targetUserId?: string
|
||||
): Promise<StaffTimeSpan[]> {
|
||||
const query = targetUserId ? `?targetUserId=${encodeURIComponent(targetUserId)}` : '';
|
||||
const spans = await apiRequest<any[]>(`/api/staff/time/spans${query}`, { token });
|
||||
|
||||
return (spans || [])
|
||||
.map((span) => {
|
||||
const started = span.startedAt ? new Date(span.startedAt) : null;
|
||||
const ended = span.endedAt ? new Date(span.endedAt) : new Date();
|
||||
const durationMinutes =
|
||||
started && ended ? Math.max(0, Math.floor((ended.getTime() - started.getTime()) / 60000)) : 0;
|
||||
|
||||
return {
|
||||
id: span.sourceEventIds?.[0] ?? null,
|
||||
eventIds: span.sourceEventIds || [],
|
||||
state: span.status || 'draft',
|
||||
started_at: span.startedAt,
|
||||
stopped_at: span.endedAt || null,
|
||||
duration_minutes: durationMinutes,
|
||||
user_id: targetUserId || null,
|
||||
type: span.type || 'work',
|
||||
description: span.payload?.description || '',
|
||||
} as StaffTimeSpan;
|
||||
})
|
||||
.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
|
||||
}
|
||||
|
||||
export async function createStaffTimeEvent(
|
||||
token: string,
|
||||
payload: {
|
||||
eventtype: string;
|
||||
eventtime: string;
|
||||
user_id: string;
|
||||
description?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await apiRequest('/api/staff/time/event', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
eventtype: payload.eventtype,
|
||||
eventtime: payload.eventtime,
|
||||
user_id: payload.user_id,
|
||||
payload: payload.description ? { description: payload.description } : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitStaffTime(token: string, eventIds: string[]): Promise<void> {
|
||||
await apiRequest('/api/staff/time/submit', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { eventIds },
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveStaffTime(
|
||||
token: string,
|
||||
eventIds: string[],
|
||||
employeeUserId: string
|
||||
): Promise<void> {
|
||||
await apiRequest('/api/staff/time/approve', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { eventIds, employeeUserId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectStaffTime(
|
||||
token: string,
|
||||
eventIds: string[],
|
||||
employeeUserId: string,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
await apiRequest('/api/staff/time/reject', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { eventIds, employeeUserId, reason },
|
||||
});
|
||||
}
|
||||
44
mobile/src/lib/token-storage.ts
Normal file
44
mobile/src/lib/token-storage.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const TOKEN_KEY = 'fedeo.mobile.auth.token';
|
||||
|
||||
let memoryToken: string | null = null;
|
||||
|
||||
async function hasSecureStore(): Promise<boolean> {
|
||||
try {
|
||||
return await SecureStore.isAvailableAsync();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredToken(): Promise<string | null> {
|
||||
if (await hasSecureStore()) {
|
||||
const token = await SecureStore.getItemAsync(TOKEN_KEY);
|
||||
memoryToken = token;
|
||||
return token;
|
||||
}
|
||||
|
||||
return memoryToken;
|
||||
}
|
||||
|
||||
export async function setStoredToken(token: string): Promise<void> {
|
||||
memoryToken = token;
|
||||
|
||||
if (await hasSecureStore()) {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, token);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStoredToken(): Promise<void> {
|
||||
memoryToken = null;
|
||||
|
||||
if (await hasSecureStore()) {
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenStorageInfo = {
|
||||
mode: 'secure-store',
|
||||
key: TOKEN_KEY,
|
||||
} as const;
|
||||
186
mobile/src/providers/auth-provider.tsx
Normal file
186
mobile/src/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
|
||||
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
|
||||
|
||||
export type AuthUser = MeResponse['user'];
|
||||
|
||||
type AuthContextValue = {
|
||||
isBootstrapping: boolean;
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
tenants: Tenant[];
|
||||
activeTenantId: number | null;
|
||||
activeTenant: Tenant | null;
|
||||
profile: Record<string, unknown> | null;
|
||||
permissions: string[];
|
||||
requiresTenantSelection: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
switchTenant: (tenantId: number) => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
function normalizeTenantId(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
|
||||
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
setUser(null);
|
||||
setTenants([]);
|
||||
setActiveTenantId(null);
|
||||
setProfile(null);
|
||||
setPermissions([]);
|
||||
}, []);
|
||||
|
||||
const hydrateSession = useCallback(
|
||||
async (tokenToUse: string) => {
|
||||
const me = await fetchMe(tokenToUse);
|
||||
setUser(me.user);
|
||||
setTenants(me.tenants || []);
|
||||
setActiveTenantId(normalizeTenantId(me.activeTenant));
|
||||
setProfile(me.profile || null);
|
||||
setPermissions(me.permissions || []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await clearStoredToken();
|
||||
setToken(null);
|
||||
resetSession();
|
||||
}, [resetSession]);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
if (!token) {
|
||||
resetSession();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await hydrateSession(token);
|
||||
} catch {
|
||||
await logout();
|
||||
}
|
||||
}, [hydrateSession, logout, resetSession, token]);
|
||||
|
||||
useEffect(() => {
|
||||
async function bootstrap() {
|
||||
const storedToken = await getStoredToken();
|
||||
|
||||
if (!storedToken) {
|
||||
setIsBootstrapping(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await hydrateSession(storedToken);
|
||||
setToken(storedToken);
|
||||
} catch {
|
||||
await clearStoredToken();
|
||||
setToken(null);
|
||||
resetSession();
|
||||
} finally {
|
||||
setIsBootstrapping(false);
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
}, [hydrateSession, resetSession]);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
const nextToken = await loginWithEmailPassword(email, password);
|
||||
await setStoredToken(nextToken);
|
||||
await hydrateSession(nextToken);
|
||||
setToken(nextToken);
|
||||
},
|
||||
[hydrateSession]
|
||||
);
|
||||
|
||||
const switchTenant = useCallback(
|
||||
async (tenantId: number) => {
|
||||
if (!token) {
|
||||
throw new Error('No active session found.');
|
||||
}
|
||||
|
||||
const nextToken = await switchTenantRequest(tenantId, token);
|
||||
await setStoredToken(nextToken);
|
||||
await hydrateSession(nextToken);
|
||||
setToken(nextToken);
|
||||
},
|
||||
[token, hydrateSession]
|
||||
);
|
||||
|
||||
const activeTenant = useMemo(
|
||||
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
|
||||
[activeTenantId, tenants]
|
||||
);
|
||||
|
||||
const requiresTenantSelection = Boolean(token) && tenants.length > 0 && !activeTenantId;
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isBootstrapping,
|
||||
token,
|
||||
user,
|
||||
tenants,
|
||||
activeTenantId,
|
||||
activeTenant,
|
||||
profile,
|
||||
permissions,
|
||||
requiresTenantSelection,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
switchTenant,
|
||||
}),
|
||||
[
|
||||
activeTenant,
|
||||
activeTenantId,
|
||||
isBootstrapping,
|
||||
login,
|
||||
logout,
|
||||
permissions,
|
||||
profile,
|
||||
refreshUser,
|
||||
requiresTenantSelection,
|
||||
switchTenant,
|
||||
tenants,
|
||||
token,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used inside AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTokenStorageInfo() {
|
||||
return tokenStorageInfo;
|
||||
}
|
||||
Reference in New Issue
Block a user