feat: Implement AccountManager

Replace simple keystore with a full-featured AccountManager that handles
identity management, account creation, and authentication. Update account-
related UI components to integrate with the new system.
This commit is contained in:
Shibo Lyu 2025-04-13 02:23:06 +08:00
parent 99a6ea0459
commit 308eef1cff
5 changed files with 138 additions and 27 deletions

View file

@ -0,0 +1,121 @@
import { type AccountKeyDB, openAccountKeyDB } from './accountKeyDB';
import {
BlahIdentity,
type BlahIdentityDescription,
type BlahProfile
} from '@blah-im/core/identity';
import { type IdentityDB, openIdentityDB } from './identityFileDB';
import { BlahKeyPair } from '@blah-im/core/crypto';
import { persisted } from 'svelte-persisted-store';
import { browser } from '$app/environment';
export type Account = BlahIdentityDescription & {
holdingIdPrivate: boolean;
holdingPrivateOfActKeyId?: string;
};
const localStorageCurrentAccountIdKey = 'weblah-current-account-id-key';
class AccountManager {
private keyDB: AccountKeyDB | undefined;
private identityDB: IdentityDB | undefined;
accounts: Account[] = $state([]);
inProgress: boolean = $state(true);
currentAccountId: string | null = $state(null);
currentAccount: Account | null = $derived(
this.accounts.find((account) => account.id_key === this.currentAccountId) ?? null
);
constructor() {
if (browser) {
this.currentAccountId = localStorage.getItem(localStorageCurrentAccountIdKey);
}
$effect.root(() => {
$effect(() =>
this.currentAccountId
? localStorage.setItem(localStorageCurrentAccountIdKey, this.currentAccountId)
: localStorage.removeItem(localStorageCurrentAccountIdKey)
);
});
(async () => {
this.inProgress = true;
const [keyDB, identityDB] = await Promise.all([openAccountKeyDB(), openIdentityDB()]);
this.keyDB = keyDB;
this.identityDB = identityDB;
await this.loadAccounts();
this.inProgress = false;
})();
}
async loadAccounts() {
if (!this.keyDB || !this.identityDB) throw new Error('Account manager not initialized');
this.inProgress = true;
const accountCreds = await this.keyDB.fetchAllAccounts();
const identityFileMap = await this.identityDB.fetchIdentities(
accountCreds.map((x) => x.idKeyId)
);
const accounts: Account[] = accountCreds.flatMap((creds) => {
const identityFile = identityFileMap.get(creds.idKeyId);
if (!identityFile) return [];
return [
{
...identityFile,
holdingIdPrivate: !!creds.encodedIdKeyPair,
holdingPrivateOfActKey: creds.actKeyPair.id
}
];
});
this.accounts = accounts;
this.inProgress = false;
}
async identityForAccount(
accountOrIdKeyId: Account | string,
password?: string
): Promise<BlahIdentity> {
if (!this.keyDB || !this.identityDB) throw new Error('Account manager not initialized');
const idKeyId =
typeof accountOrIdKeyId === 'string' ? accountOrIdKeyId : accountOrIdKeyId.id_key;
const identityFile = await this.identityDB.fetchIdentity(idKeyId);
if (!identityFile) throw new Error('Identity file not found');
const accountCreds = await this.keyDB.fetchAccount(idKeyId);
const encodedIdKeyPair = accountCreds?.encodedIdKeyPair;
const idKeyPair = encodedIdKeyPair
? await BlahKeyPair.fromEncoded(encodedIdKeyPair, password)
: undefined;
const actKeyPair = accountCreds?.actKeyPair;
return await BlahIdentity.fromIdentityDescription(identityFile, idKeyPair, actKeyPair);
}
async saveIdentityDescription(identity: BlahIdentity) {
if (!this.identityDB) throw new Error('Account manager not initialized');
const identityDesc = identity.generateIdentityDescription();
await this.identityDB.updateIdentity(identityDesc);
await this.loadAccounts();
}
async createAccount(profile: BlahProfile, password: string): Promise<string> {
if (!this.keyDB) throw new Error('Account manager not initialized');
const idKeyPair = await BlahKeyPair.generate(true);
const actKeyPair = await BlahKeyPair.generate(false);
const identity = await BlahIdentity.create(idKeyPair, actKeyPair, profile);
const encodedIdKeyPair = await idKeyPair.encode(password);
await this.keyDB.addAccount(idKeyPair.id, actKeyPair, encodedIdKeyPair);
await this.saveIdentityDescription(identity);
return idKeyPair.id;
}
}
export default new AccountManager();

View file

@ -1,8 +0,0 @@
import type { BlahSignedPayload } from '@blah-im/core/crypto';
export type BlahActKeyEntry = {
exp: number;
};
export type BlahSignedActKeyEntry = BlahSignedPayload<BlahActKeyEntry>;
export type BlahKeyBox = BlahSignedPayload<BlahSignedActKeyEntry[]>;

View file

@ -1,10 +0,0 @@
import { persisted } from 'svelte-persisted-store';
import type { EncodedBlahKeyPair } from '@blah-im/core/crypto';
import { derived } from 'svelte/store';
export const keyStore = persisted<EncodedBlahKeyPair[]>('weblah-keypairs', []);
export const currentKeyIndex = persisted<number>('weblah-current-key-index', 0);
export const currentKeyPair = derived(
[keyStore, currentKeyIndex],
([keyStore, currentKeyIndex]) => keyStore[currentKeyIndex]
);

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { GroupedListItem, GroupedListSection } from '$lib/components/GroupedList';
import { ArrowRightEndOnRectangle, UserPlus } from 'svelte-hero-icons';
import { ArrowRightEndOnRectangle, Plus, UserPlus } from 'svelte-hero-icons';
import SettingsListItem from './SettingsListItem.svelte';
import {
openAccountStore,
@ -66,6 +66,12 @@
{/if}
<GroupedListSection>
<SettingsListItem icon={ArrowRightEndOnRectangle} route="/account/add">Sign in</SettingsListItem>
{#if ($accountStore?.length ?? 0) > 0}
<SettingsListItem icon={Plus} route="/account/add">Add Account</SettingsListItem>
{:else}
<SettingsListItem icon={ArrowRightEndOnRectangle} route="/account/add">
Sign in
</SettingsListItem>
<SettingsListItem icon={UserPlus} route="/account/new">Create Account</SettingsListItem>
{/if}
</GroupedListSection>

View file

@ -4,11 +4,11 @@
import { tw } from '$lib/tw';
import {
Bell,
Cog,
DevicePhoneMobile,
InformationCircle,
LockClosed,
QuestionMarkCircle
QuestionMarkCircle,
User
} from 'svelte-hero-icons';
import { scale } from 'svelte/transition';
import SettingsListItem from './SettingsListItem.svelte';
@ -20,11 +20,10 @@
}
let { class: className = '' }: Props = $props();
</script>
<div
class={tw('flex flex-col bg-sb-secondary shadow-md', className)}
class={tw('bg-sb-secondary flex flex-col shadow-md', className)}
transition:scale={{ duration: 250, start: 0.95 }}
>
<PageHeader>
@ -36,10 +35,13 @@
<SettingsAccountSections />
<GroupedListSection>
<SettingsListItem icon={Cog} route="">General</SettingsListItem>
<SettingsListItem icon={User} route="">My Profile</SettingsListItem>
<GroupedListItem icon={DevicePhoneMobile}>Devices</GroupedListItem>
</GroupedListSection>
<GroupedListSection>
<GroupedListItem icon={Bell}>Notifications</GroupedListItem>
<GroupedListItem icon={LockClosed}>Privacy and Security</GroupedListItem>
<GroupedListItem icon={DevicePhoneMobile}>Devices</GroupedListItem>
</GroupedListSection>
<GroupedListSection>