Authentication
Vetty has five authentication entry points; this page shows each end-to-end.
- Clinic staff login via the React SPA.
- Pet owner self-registration via the React SPA.
- System admin login to Filament.
- Pet owner login + register from the vetty Flutter app.
- Clinic staff login from the vetdoctor Flutter app.
All API-driven flows end with a Sanctum bearer token; the SPA stores it in localStorage, the Flutter apps persist it via shared_preferences.
1. Clinic staff login (SPA → Laravel)
Request
POST /api/v1/auth/login
Content-Type: application/json
{ "email": "sarah.lee@pawsclaws.com", "password": "password" }
Response shape
{
"data": {
"id": 2,
"name": "Dr. Sarah Lee",
"email": "sarah.lee@pawsclaws.com",
"phone": "+1 555 0102",
"account_type": "staff",
"current_tenant": {
"id": 1,
"name": "Paws & Claws Veterinary",
"slug": "paws-claws",
"status": "active",
"subscription_plan": "premium"
},
"tenants": [ /* pivot-enriched tenants */ ]
}
}
Open item:
AuthController::logincurrently does not emit a Sanctum token on the happy path. The SPA is already ready to consume one — the fix is a one-liner:$token = $user->createToken('clinic-spa')->plainTextToken;return UserResource::make($user)->additional(['meta' => ['token' => $token]]);
2. Pet owner self-registration
The owner can then use their email/password on /auth/login — the same endpoint serves staff and owners. The SPA branches based on account_type.
3. System admin login (Filament)
Standard Laravel session flow:
/systemadmin/loginrenders Filament's login Blade view.- Credentials are posted to the
systemadminguard (defined inconfig/auth.php). - Successful authentication creates a session row (
sessionstable) and sets thelaravel_sessioncookie. - All subsequent
/systemadmin/*requests carry the cookie and a CSRF token.
There is no API surface for system admins; all actions happen through Filament.
4. vetty (Flutter pet-owner app)
Register and forgot-password follow the same shape:
// Owner registration
await _api.post('/auth/owner/register', body: {
'name': name,
'email': email,
'password': password,
'password_confirmation': password,
'phone': phone,
'device_name': 'vetty-mobile',
});
// Forgot password — backend emails a link into the web SPA
await _api.post('/auth/forgot-password', body: {
'email': email,
'redirect_base': '${Env.webBase}/reset-password',
});
The forgot-password email opens in the user's browser, not back in the Flutter app — the web SPA handles the reset-password form end-to-end. There is no deep-link interception, by design.
5. vetdoctor (Flutter clinic app)
Identical to vetty's login except the response envelope includes a tenant block that the app persists alongside the user:
final res = await _api.post('/auth/login', body: {
'email': email,
'password': password,
'device_name': 'vetdoctor-mobile',
});
final data = res['data'] ?? res;
Map<String, dynamic>? tenant;
if (data is Map && data['tenant'] is Map) {
tenant = Map<String, dynamic>.from(data['tenant']);
}
await _store.set(token: token, user: user, tenant: tenant);
The persisted tenant is what scopes subsequent /tenant/{id}/… calls (today's appointments, share redemption). Multi-tenant switching is deliberately web-only — a vet with multiple clinics picks the current tenant on login and uses the web app to switch.
There is no register screen on vetdoctor — clinic staff are provisioned through Filament or the SPA, never self-service.
Token handling in the SPA
The SPA holds the staff/owner token in localStorage.access_token. On every request, the Http*Service classes inject it:
class HttpPetService {
list({ tenantId }) {
return this.client.get(`/tenant/${tenantId}/pets`, {
headers: { Authorization: `Bearer ${this.getAccessToken()}` },
});
}
}
Token lifecycle
| Event | What happens |
|---|---|
| Login succeeds | Token is saved to localStorage.access_token |
| Logout | localStorage.removeItem('access_token'), other vetty.* keys cleared |
| Token expires (future) | Backend returns 401 → SPA redirects to the login module |
Token handling in the Flutter apps
Both apps share an ApiClient that pulls auth.token off the injected AuthStore and sets Authorization: Bearer <token> on every outgoing request. On a 401 the client clears the store, which triggers the _Root listener and flips the UI back to LoginScreen automatically.
// lib/core/api/api_client.dart
final token = _auth?.token;
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
// ...
if (response.statusCode == 401) {
await _auth?.clear();
throw ApiException(message: 'Unauthenticated', statusCode: 401);
}
Session keys reference
| Storage | Key | Owned by | Purpose |
|---|---|---|---|
| localStorage | access_token | AuthContext | Bearer token for API calls |
| localStorage | vetty.session | AuthContext | Normalised user + currentTenant snapshot |
| localStorage | vetty.tenants | TenantContext | List of tenants the user can pick from |
| localStorage | vetty.currentTenant | TenantContext | Last selected tenant |
| Cookie | laravel_session | Laravel (Filament) | Server-side session for Filament admin |
| Cookie | XSRF-TOKEN | Laravel | CSRF token for Filament forms |
| SharedPreferences (Android / iOS) | vetty.auth.token / vetty.auth.user | vetty AuthStore | Bearer + user envelope |
| SharedPreferences (Android / iOS) | vetdoctor.auth.token / vetdoctor.auth.user / vetdoctor.auth.tenant | vetdoctor AuthStore | Bearer + user + pinned tenant |
Logging out
SPA side:
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('vetty.session');
localStorage.removeItem('vetty.currentTenant');
localStorage.removeItem('vetty.tenants');
clearAuthState();
clearTenantState();
}
Backend side (recommended enhancement): POST /api/v1/auth/logout that calls $user->currentAccessToken()->delete() to revoke the Sanctum token.
Flutter side:
// Best-effort — always clears the local store even if the server call fails
Future<void> logout() async {
try { await _api.post('/auth/logout'); } catch (_) {}
await _store.clear();
}