Greenlens/services/authService.ts

138 lines
4.6 KiB
TypeScript

import * as SecureStore from 'expo-secure-store';
import { AuthDb } from './database';
const SESSION_KEY = 'greenlens_session_v3';
const BACKEND_URL = (
process.env.EXPO_PUBLIC_BACKEND_URL ||
process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL ||
''
).trim();
export interface AuthSession {
userId: number; // local SQLite id (for plants/settings queries)
serverUserId: string; // server-side user id (in JWT)
email: string;
name: string;
token: string; // JWT from server
loggedInAt: string;
}
// ─── Internal helpers ──────────────────────────────────────────────────────
const clearStoredSession = async (): Promise<void> => {
await SecureStore.deleteItemAsync(SESSION_KEY);
};
const authPost = async (path: string, body: object): Promise<{ userId: string; email: string; name: string; token: string }> => {
const hasBackendUrl = Boolean(BACKEND_URL);
const url = hasBackendUrl ? `${BACKEND_URL}${path}` : path;
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (e) {
if (!hasBackendUrl) {
throw new Error('BACKEND_URL_MISSING');
}
throw new Error('NETWORK_ERROR');
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const code = (data as any).code || 'AUTH_ERROR';
const msg = (data as any).message || '';
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
throw new Error(code);
}
return data as any;
};
const buildSession = (data: { userId: string; email: string; name: string; token: string }): AuthSession => {
const localUser = AuthDb.ensureLocalUser(data.email, data.name);
return {
userId: localUser.id,
serverUserId: data.userId,
email: data.email,
name: data.name,
token: data.token,
loggedInAt: new Date().toISOString(),
};
};
// ─── AuthService ───────────────────────────────────────────────────────────
export const AuthService = {
async getSession(): Promise<AuthSession | null> {
try {
const raw = await SecureStore.getItemAsync(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AuthSession>;
if (!parsed.token || !parsed.serverUserId || !parsed.userId) {
await clearStoredSession();
return null;
}
return parsed as AuthSession;
} catch {
await clearStoredSession();
return null;
}
},
async signUp(email: string, name: string, password: string): Promise<AuthSession> {
const data = await authPost('/auth/signup', { email, name, password });
const session = buildSession(data);
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
return session;
},
async login(email: string, password: string): Promise<AuthSession> {
const data = await authPost('/auth/login', { email, password });
const session = buildSession(data);
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
return session;
},
async logout(): Promise<void> {
await clearStoredSession();
},
async updateSessionName(name: string): Promise<void> {
const session = await this.getSession();
if (!session) return;
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...session, name }));
},
async validateWithServer(): Promise<'valid' | 'invalid' | 'unreachable'> {
const session = await this.getSession();
if (!session) return 'invalid';
if (!BACKEND_URL) return 'unreachable';
try {
const response = await fetch(`${BACKEND_URL}/v1/billing/summary`, {
headers: { Authorization: `Bearer ${session.token}` },
});
if (response.status === 401 || response.status === 403) return 'invalid';
return 'valid';
} catch {
return 'unreachable';
}
},
async checkIfFirstRun(): Promise<boolean> {
const flag = await SecureStore.getItemAsync('greenlens_first_run_complete');
return flag !== 'true';
},
async markFirstRunComplete(): Promise<void> {
await SecureStore.setItemAsync('greenlens_first_run_complete', 'true');
},
async clearAllData(): Promise<void> {
await clearStoredSession();
await SecureStore.deleteItemAsync('greenlens_first_run_complete');
// Note: SQLite tables aren't cleared here to avoid destroying user data
// without explicit consent, but session tokens are wiped.
},
};