docs
/
Client Integration

Flutter auth

Two kinds of people, three endings to a sign-in, verification codes, shared devices and remembered accounts.

Two identities, kept apart

AppEngine has two kinds of people and they are different records with different sign-in routes. The client keeps them visible rather than hiding the difference behind one signIn:

appmint.staff.signIn(email, password);      // employees, managers, admins
appmint.customers.signIn(email, password);  // the people you serve

They are not interchangeable, and some routes behave differently for each — a staff account with no matching customer record cannot reach the security routes at all. Picking the wrong one produces confusing failures rather than a clean refusal, so choose deliberately.

Sign-in has three endings

It does not return a token. It returns a sealed result, and the compiler makes you handle every case:

switch (await appmint.customers.signIn(email, password)) {
  case SignedIn(:final user):
    // there is a session — go
  case NeedsVerification(:final challenge):
    // the password was right; a code is needed
  case SignInRejected(:final message):
    showError(message);   // the server's own wording, safe to show
}
Why this is a sealed type

A verification challenge arrives as an ordinary 200 with no token in it. Two shipped apps read that as success, stored an empty token, and threw people out on the first real request — a sign-in loop that never mentioned the code that had just been emailed. As a result type, forgetting that case is a build error instead of an outage.

Finishing a verification

The challenge carries everything needed, including the way out when the second factor is on a phone that is lost or flat:

case NeedsVerification(:final challenge):
  challenge.method;        // email | sms | authenticator
  challenge.message;       // the server's sentence
  challenge.isNewDevice;   // challenged because the device is unrecognised
  challenge.sentTo;        // where it went, when it went anywhere

  final result = await challenge.verify(code);   // trustDevice: true by default
  await challenge.resend();
  await challenge.sendByAnotherMethod(VerificationMethod.email);
  challenge.cancel();

verify returns the same three-way result, so a wrong code comes back as SignInRejected with the server's wording and the challenge stays alive — keep the sheet open and let them try again.

Two details worth honouring in your UI:

  • challenge.method.canResend is false for authenticator. That code is computed on the device; offering "send it again" would be a lie.
  • Any factor the account has enrolled can answer the challenge, so sendByAnotherMethod only changes how the code arrives. It is the difference between a locked-out user and a mildly inconvenienced one.

trustDevice: true stops this device being challenged again for a while — the difference between asking once and asking at every launch.

The other ways in

await appmint.customers.sendMagicCode(email);
await appmint.customers.verifyMagicCode(email, code);

A magic code replaces the password rather than adding to it.

await appmint.staff.signInWithPasscode(employeeId: '4471', pin: '123456');
await appmint.staff.signInWithPasscode(employeeId: '4471', cardUid: uid);

Passcode and NFC card sign-in are for shared devices — a till, a host stand, a door scanner — where typing a password in front of a queue is the slowest thing in the building. The server deliberately exempts them from verification challenges so service is never interrupted, which also makes them the way back in when an organization has just switched two-factor on.

Staying signed in

await appmint.auth.restore();        // once at startup; null if nobody is signed in
appmint.auth.currentUser;
appmint.auth.isSignedIn;
appmint.auth.changes.listen(...);    // sign-in, sign-out, forced sign-out
await appmint.auth.signOut();

The client refreshes the session on a 401 and retries once. If that fails it clears the session, emits null on changes, and the failing call throws AppmintSessionExpiredException — so a dead session ends in one obvious place rather than as a scattering of odd errors.

Remembered accounts

final saved = await appmint.auth.savedSessions();   // newest first, one per org
await appmint.auth.signInWithSaved(saved.first);
await appmint.auth.forgetSession(email, orgId);

Kept per organization, so somebody who works across two gets a row for each. A row whose token can no longer be renewed is marked expired rather than deleted — the person is still known, they just have to type a password.

Changing organization

await appmint.switchOrganization('other-org');

This ends the current session on purpose: a token issued for one organization is not valid for another. The cached app token is dropped too, so the next call authenticates against the new one.

What is not here

No biometric unlock, and no second factor the client invents on its own — the only codes are the ones AppEngine issues. If an organization turns on two-factor or new-device verification, NeedsVerification is how it reaches you.