Files
sub2api-mobile/src/store/admin-config.ts
2026-03-09 12:31:08 +08:00

345 lines
9.9 KiB
TypeScript

import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
const { proxy } = require('valtio');
const BASE_URL_KEY = 'sub2api_base_url';
const ADMIN_KEY_KEY = 'sub2api_admin_api_key';
const ACCOUNTS_KEY = 'sub2api_accounts';
const ACTIVE_ACCOUNT_ID_KEY = 'sub2api_active_account_id';
const IS_WEB = Platform.OS === 'web';
export type AdminAccountProfile = {
id: string;
label: string;
baseUrl: string;
adminApiKey: string;
updatedAt: string;
enabled?: boolean;
};
function createAccountId() {
return `acct_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function getAccountLabel(baseUrl: string) {
try {
const url = new URL(baseUrl);
return url.host || baseUrl;
} catch {
return baseUrl;
}
}
function normalizeConfig(input: { baseUrl: string; adminApiKey: string }) {
return {
baseUrl: input.baseUrl.trim().replace(/\/$/, ''),
adminApiKey: input.adminApiKey.trim(),
};
}
function sortAccounts(accounts: AdminAccountProfile[]) {
return [...accounts].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
function normalizeAccount(account: AdminAccountProfile): AdminAccountProfile {
return {
...account,
adminApiKey: account.adminApiKey ?? '',
enabled: account.enabled ?? true,
};
}
function sanitizeAccountsForWeb(accounts: AdminAccountProfile[]) {
if (!IS_WEB) {
return accounts;
}
return accounts.map((account) => ({
...account,
adminApiKey: '',
}));
}
function persistAdminApiKey(value: string) {
if (IS_WEB) {
return deleteItem(ADMIN_KEY_KEY);
}
return setItem(ADMIN_KEY_KEY, value);
}
function persistAccounts(accounts: AdminAccountProfile[]) {
return setItem(ACCOUNTS_KEY, JSON.stringify(sanitizeAccountsForWeb(accounts)));
}
export function hasAuthenticatedAdminSession(config: { baseUrl: string; adminApiKey: string }) {
const hasBaseUrl = Boolean(config.baseUrl.trim());
if (!hasBaseUrl) {
return false;
}
if (!IS_WEB) {
return true;
}
return Boolean(config.adminApiKey.trim());
}
function getNextActiveAccount(accounts: AdminAccountProfile[], activeAccountId?: string) {
const enabledAccounts = accounts.filter((account) => account.enabled !== false);
if (activeAccountId) {
const preferred = enabledAccounts.find((account) => account.id === activeAccountId);
if (preferred) {
return preferred;
}
}
return enabledAccounts[0];
}
export function getDefaultAdminConfig() {
return {
baseUrl: '',
adminApiKey: '',
};
}
async function getItem(key: string) {
try {
if (Platform.OS === 'web') {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(key);
}
return await SecureStore.getItemAsync(key);
} catch {
return null;
}
}
async function setItem(key: string, value: string) {
try {
if (Platform.OS === 'web') {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
return;
}
await SecureStore.setItemAsync(key, value);
} catch {
return;
}
}
async function deleteItem(key: string) {
try {
if (Platform.OS === 'web') {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
}
return;
}
await SecureStore.deleteItemAsync(key);
} catch {
return;
}
}
export const adminConfigState = proxy({
...getDefaultAdminConfig(),
accounts: [] as AdminAccountProfile[],
activeAccountId: '',
hydrated: false,
saving: false,
});
export async function hydrateAdminConfig() {
const defaults = getDefaultAdminConfig();
try {
const [baseUrl, adminApiKey, rawAccounts, activeAccountId] = await Promise.all([
getItem(BASE_URL_KEY),
getItem(ADMIN_KEY_KEY),
getItem(ACCOUNTS_KEY),
getItem(ACTIVE_ACCOUNT_ID_KEY),
]);
let accounts: AdminAccountProfile[] = [];
if (rawAccounts) {
try {
const parsed = JSON.parse(rawAccounts) as AdminAccountProfile[];
accounts = Array.isArray(parsed) ? sanitizeAccountsForWeb(parsed.map((account) => normalizeAccount(account))) : [];
} catch {
accounts = [];
}
}
if (accounts.length === 0 && baseUrl) {
const legacyConfig = normalizeConfig({
baseUrl,
adminApiKey: IS_WEB ? defaults.adminApiKey : adminApiKey ?? defaults.adminApiKey,
});
accounts = [
{
id: createAccountId(),
label: getAccountLabel(legacyConfig.baseUrl),
...legacyConfig,
updatedAt: new Date().toISOString(),
enabled: true,
},
];
}
const sortedAccounts = sortAccounts(accounts);
const activeAccount = getNextActiveAccount(sortedAccounts, activeAccountId ?? undefined);
const nextActiveAccountId = activeAccount?.id || '';
adminConfigState.accounts = sortedAccounts;
adminConfigState.activeAccountId = nextActiveAccountId;
adminConfigState.baseUrl = activeAccount?.baseUrl ?? defaults.baseUrl;
adminConfigState.adminApiKey = activeAccount?.adminApiKey ?? defaults.adminApiKey;
await Promise.all([
persistAccounts(sortedAccounts),
nextActiveAccountId ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccountId) : deleteItem(ACTIVE_ACCOUNT_ID_KEY),
setItem(BASE_URL_KEY, activeAccount?.baseUrl ?? defaults.baseUrl),
persistAdminApiKey(activeAccount?.adminApiKey ?? defaults.adminApiKey),
]);
} finally {
adminConfigState.hydrated = true;
}
}
export async function saveAdminConfig(input: { baseUrl: string; adminApiKey: string }) {
adminConfigState.saving = true;
try {
const normalized = normalizeConfig(input);
const nextUpdatedAt = new Date().toISOString();
const existingAccount = adminConfigState.accounts.find(
(account: AdminAccountProfile) => account.baseUrl === normalized.baseUrl && account.adminApiKey === normalized.adminApiKey
);
const nextAccount: AdminAccountProfile = existingAccount
? {
...existingAccount,
label: getAccountLabel(normalized.baseUrl),
updatedAt: nextUpdatedAt,
}
: {
id: createAccountId(),
label: getAccountLabel(normalized.baseUrl),
...normalized,
updatedAt: nextUpdatedAt,
enabled: true,
};
const nextAccounts = sortAccounts([
nextAccount,
...adminConfigState.accounts.filter((account: AdminAccountProfile) => account.id !== nextAccount.id),
]);
await Promise.all([
setItem(BASE_URL_KEY, normalized.baseUrl),
persistAdminApiKey(normalized.adminApiKey),
persistAccounts(nextAccounts),
setItem(ACTIVE_ACCOUNT_ID_KEY, nextAccount.id),
]);
adminConfigState.accounts = nextAccounts;
adminConfigState.activeAccountId = nextAccount.id;
adminConfigState.baseUrl = normalized.baseUrl;
adminConfigState.adminApiKey = normalized.adminApiKey;
} finally {
adminConfigState.saving = false;
}
}
export async function switchAdminAccount(accountId: string) {
const account = adminConfigState.accounts.find((item: AdminAccountProfile) => item.id === accountId);
if (!account) {
return;
}
if (account.enabled === false) {
return;
}
const nextAccount = {
...account,
updatedAt: new Date().toISOString(),
};
const nextAccounts = sortAccounts([
nextAccount,
...adminConfigState.accounts.filter((item: AdminAccountProfile) => item.id !== accountId),
]);
await Promise.all([
setItem(BASE_URL_KEY, nextAccount.baseUrl),
persistAdminApiKey(nextAccount.adminApiKey),
persistAccounts(nextAccounts),
setItem(ACTIVE_ACCOUNT_ID_KEY, nextAccount.id),
]);
adminConfigState.accounts = nextAccounts;
adminConfigState.activeAccountId = nextAccount.id;
adminConfigState.baseUrl = nextAccount.baseUrl;
adminConfigState.adminApiKey = nextAccount.adminApiKey;
}
export async function removeAdminAccount(accountId: string) {
const nextAccounts = adminConfigState.accounts.filter((item: AdminAccountProfile) => item.id !== accountId);
const nextActiveAccount = getNextActiveAccount(nextAccounts, adminConfigState.activeAccountId === accountId ? '' : adminConfigState.activeAccountId);
await Promise.all([
persistAccounts(nextAccounts),
nextActiveAccount ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccount.id) : deleteItem(ACTIVE_ACCOUNT_ID_KEY),
setItem(BASE_URL_KEY, nextActiveAccount?.baseUrl ?? ''),
persistAdminApiKey(nextActiveAccount?.adminApiKey ?? ''),
]);
adminConfigState.accounts = nextAccounts;
adminConfigState.activeAccountId = nextActiveAccount?.id ?? '';
adminConfigState.baseUrl = nextActiveAccount?.baseUrl ?? '';
adminConfigState.adminApiKey = nextActiveAccount?.adminApiKey ?? '';
}
export async function logoutAdminAccount() {
await Promise.all([setItem(BASE_URL_KEY, ''), persistAdminApiKey(''), deleteItem(ACTIVE_ACCOUNT_ID_KEY)]);
adminConfigState.activeAccountId = '';
adminConfigState.baseUrl = '';
adminConfigState.adminApiKey = '';
}
export async function setAdminAccountEnabled(accountId: string, enabled: boolean) {
const nextAccounts = sortAccounts(
adminConfigState.accounts.map((account: AdminAccountProfile) =>
account.id === accountId ? { ...account, enabled, updatedAt: new Date().toISOString() } : account
)
);
const nextActiveAccount = getNextActiveAccount(nextAccounts, enabled ? accountId : adminConfigState.activeAccountId);
await Promise.all([
persistAccounts(nextAccounts),
nextActiveAccount ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccount.id) : deleteItem(ACTIVE_ACCOUNT_ID_KEY),
setItem(BASE_URL_KEY, nextActiveAccount?.baseUrl ?? ''),
persistAdminApiKey(nextActiveAccount?.adminApiKey ?? ''),
]);
adminConfigState.accounts = nextAccounts;
adminConfigState.activeAccountId = nextActiveAccount?.id ?? '';
adminConfigState.baseUrl = nextActiveAccount?.baseUrl ?? '';
adminConfigState.adminApiKey = nextActiveAccount?.adminApiKey ?? '';
}