Skip to main content

Authentication

Vetty has five authentication entry points; this page shows each end-to-end.

  1. Clinic staff login via the React SPA.
  2. Pet owner self-registration via the React SPA.
  3. System admin login to Filament.
  4. Pet owner login + register from the vetty Flutter app.
  5. 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::login currently 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/login renders Filament's login Blade view.
  • Credentials are posted to the systemadmin guard (defined in config/auth.php).
  • Successful authentication creates a session row (sessions table) and sets the laravel_session cookie.
  • 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

EventWhat happens
Login succeedsToken is saved to localStorage.access_token
LogoutlocalStorage.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

StorageKeyOwned byPurpose
localStorageaccess_tokenAuthContextBearer token for API calls
localStoragevetty.sessionAuthContextNormalised user + currentTenant snapshot
localStoragevetty.tenantsTenantContextList of tenants the user can pick from
localStoragevetty.currentTenantTenantContextLast selected tenant
Cookielaravel_sessionLaravel (Filament)Server-side session for Filament admin
CookieXSRF-TOKENLaravelCSRF token for Filament forms
SharedPreferences (Android / iOS)vetty.auth.token / vetty.auth.uservetty AuthStoreBearer + user envelope
SharedPreferences (Android / iOS)vetdoctor.auth.token / vetdoctor.auth.user / vetdoctor.auth.tenantvetdoctor AuthStoreBearer + 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();
}