Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
import { authorize, refresh, revoke } from 'react-native-app-auth';
|
|
|
|
|
import Config from '../../config';
|
2026-05-20 19:07:49 -04:00
|
|
|
import apiClient, { registerTokenRefresh } from './client';
|
2026-05-20 11:36:51 -04:00
|
|
|
import { authenticateWithCredentials } from './cognito-auth';
|
Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
import {
|
|
|
|
|
tokenStorage,
|
|
|
|
|
userStorage,
|
|
|
|
|
StoredTokens,
|
|
|
|
|
StoredUser,
|
|
|
|
|
} from '../storage';
|
|
|
|
|
|
2026-05-20 13:34:08 -04:00
|
|
|
function parseUserFromIdToken(idToken: string): StoredUser {
|
|
|
|
|
const base64Url = idToken.split('.')[1];
|
|
|
|
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
2026-05-20 19:07:49 -04:00
|
|
|
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
|
|
|
|
const payload = JSON.parse(atob(padded));
|
2026-05-20 13:34:08 -04:00
|
|
|
const groups: string[] = payload['cognito:groups'] || [];
|
2026-05-20 19:07:49 -04:00
|
|
|
const role = groups.includes('sysadmins')
|
|
|
|
|
? 'SysAdmin'
|
|
|
|
|
: groups.includes('admins')
|
|
|
|
|
? 'Admin'
|
|
|
|
|
: 'Dispatcher';
|
2026-05-20 13:34:08 -04:00
|
|
|
return {
|
|
|
|
|
id: payload.sub,
|
|
|
|
|
email: payload.email,
|
|
|
|
|
displayName: payload.name || payload.email,
|
|
|
|
|
role: role as StoredUser['role'],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
const cognitoConfig = {
|
|
|
|
|
clientId: Config.COGNITO_CLIENT_ID,
|
|
|
|
|
redirectUrl: Config.COGNITO_REDIRECT_URI,
|
|
|
|
|
scopes: Config.COGNITO_SCOPES,
|
|
|
|
|
serviceConfiguration: {
|
|
|
|
|
authorizationEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/authorize`,
|
|
|
|
|
tokenEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/token`,
|
|
|
|
|
revocationEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/revoke`,
|
|
|
|
|
},
|
|
|
|
|
usePKCE: true,
|
|
|
|
|
iosPrefersEphemeralSession: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const authApi = {
|
|
|
|
|
login: async (): Promise<StoredUser> => {
|
|
|
|
|
const result = await authorize(cognitoConfig);
|
|
|
|
|
|
|
|
|
|
const tokens: StoredTokens = {
|
|
|
|
|
accessToken: result.accessToken,
|
|
|
|
|
idToken: result.idToken,
|
|
|
|
|
refreshToken: result.refreshToken,
|
|
|
|
|
expiresAt: result.accessTokenExpirationDate,
|
|
|
|
|
};
|
|
|
|
|
await tokenStorage.save(tokens);
|
|
|
|
|
|
|
|
|
|
const profile = await authApi.getMe();
|
|
|
|
|
await userStorage.save(profile);
|
|
|
|
|
return profile;
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-20 11:36:51 -04:00
|
|
|
loginWithCredentials: async (
|
|
|
|
|
email: string,
|
|
|
|
|
password: string,
|
|
|
|
|
): Promise<StoredUser> => {
|
|
|
|
|
const cognitoTokens = await authenticateWithCredentials(email, password);
|
|
|
|
|
|
|
|
|
|
const tokens: StoredTokens = {
|
|
|
|
|
accessToken: cognitoTokens.accessToken,
|
|
|
|
|
idToken: cognitoTokens.idToken,
|
|
|
|
|
refreshToken: cognitoTokens.refreshToken,
|
|
|
|
|
expiresAt: cognitoTokens.expiresAt,
|
|
|
|
|
};
|
|
|
|
|
await tokenStorage.save(tokens);
|
|
|
|
|
|
2026-05-20 13:34:08 -04:00
|
|
|
let profile: StoredUser;
|
|
|
|
|
try {
|
|
|
|
|
profile = await authApi.getMe();
|
|
|
|
|
} catch {
|
|
|
|
|
profile = parseUserFromIdToken(cognitoTokens.idToken);
|
|
|
|
|
}
|
2026-05-20 11:36:51 -04:00
|
|
|
await userStorage.save(profile);
|
|
|
|
|
return profile;
|
|
|
|
|
},
|
|
|
|
|
|
Add mobile core: API client, auth, storage, state, and offline support
Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
2026-05-17 15:09:23 -04:00
|
|
|
refreshTokens: async (): Promise<void> => {
|
|
|
|
|
const stored = await tokenStorage.get();
|
|
|
|
|
if (!stored?.refreshToken) throw new Error('No refresh token');
|
|
|
|
|
|
|
|
|
|
const result = await refresh(cognitoConfig, {
|
|
|
|
|
refreshToken: stored.refreshToken,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await tokenStorage.save({
|
|
|
|
|
accessToken: result.accessToken,
|
|
|
|
|
idToken: result.idToken || stored.idToken,
|
|
|
|
|
refreshToken: result.refreshToken || stored.refreshToken,
|
|
|
|
|
expiresAt: result.accessTokenExpirationDate,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
logout: async (): Promise<void> => {
|
|
|
|
|
const stored = await tokenStorage.get();
|
|
|
|
|
if (stored?.accessToken) {
|
|
|
|
|
try {
|
|
|
|
|
await revoke(cognitoConfig, {
|
|
|
|
|
tokenToRevoke: stored.accessToken,
|
|
|
|
|
includeBasicAuth: false,
|
|
|
|
|
sendClientId: true,
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// Best-effort revocation
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await tokenStorage.clear();
|
|
|
|
|
await userStorage.clear();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getMe: async (): Promise<StoredUser> => {
|
|
|
|
|
const res = await apiClient.get('/users/me');
|
|
|
|
|
return res.data;
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-05-20 19:07:49 -04:00
|
|
|
|
|
|
|
|
registerTokenRefresh(() => authApi.refreshTokens());
|