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 serveThey 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
}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.canResendis false forauthenticator. 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
sendByAnotherMethodonly 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.