feat: Implement identity lock screen for sensitive user actions and introduce avatar generation.

This commit is contained in:
Christomatt
2026-01-30 04:44:57 +01:00
parent 4c9afa42fe
commit 10a54a0ea9
16 changed files with 519 additions and 134 deletions
+63
View File
@@ -0,0 +1,63 @@
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
export async function generateAndUploadAvatar(handle: string): Promise<string | null> {
try {
// 1. Fetch the avatar from DiceBear
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${handle}`;
const response = await fetch(dicebearUrl);
if (!response.ok) {
console.error(`Failed to fetch avatar from DiceBear: ${response.statusText}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 2. Upload to S3
const s3 = new S3Client({
region: process.env.STORAGE_REGION || 'us-east-1',
endpoint: process.env.STORAGE_ENDPOINT,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
},
forcePathStyle: true,
});
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
// Sanitize handle for filename just in case
const safeHandle = handle.replace(/[^a-zA-Z0-9]/g, '');
const filename = `${uuidv4()}-${safeHandle}-avatar.svg`;
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: filename,
Body: buffer,
ContentType: 'image/svg+xml',
ACL: 'public-read',
}));
// 3. Construct Public URL
let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else if (process.env.STORAGE_ENDPOINT) {
// Basic fallback construction if base url is not set but endpoint is
// This assumes path style access is okay if no custom domain
const endpoint = process.env.STORAGE_ENDPOINT.replace(/\/+$/, '');
url = `${endpoint}/${bucket}/${filename}`;
} else {
console.warn('Storage public URL not configured properly');
return null;
}
return url;
} catch (error) {
console.error('Error generating/uploading avatar:', error);
return null;
}
}
+8 -1
View File
@@ -10,6 +10,7 @@ import { generateKeyPair } from '@/lib/crypto/keys';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { generateAndUploadAvatar } from '@/lib/auth/avatar';
const SESSION_COOKIE_NAME = 'synapsis_session';
const SESSION_EXPIRY_DAYS = 30;
@@ -157,17 +158,23 @@ export async function registerUser(
const did = generateDID();
const passwordHash = await hashPassword(password);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Generate avatar using full handle (@user@domain) for global uniqueness
const fullHandle = `${handle.toLowerCase()}@${nodeDomain}`;
const avatarUrl = await generateAndUploadAvatar(fullHandle);
const [user] = await db.insert(users).values({
did,
handle: handle.toLowerCase(),
email: email.toLowerCase(),
passwordHash,
displayName: displayName || handle,
avatarUrl,
publicKey,
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
}).returning();
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
await upsertHandleEntries([{
handle: user.handle,
did: user.did,
+41 -2
View File
@@ -25,7 +25,8 @@ interface AuthContextType {
login: (user: User) => void;
logout: () => Promise<void>;
showUnlockPrompt: boolean;
setShowUnlockPrompt: (show: boolean) => void;
setShowUnlockPrompt: (show: boolean, onSuccess?: () => void) => void;
signUserAction: (action: string, data: any) => Promise<any>;
}
const AuthContext = createContext<AuthContextType>({
@@ -41,6 +42,7 @@ const AuthContext = createContext<AuthContextType>({
logout: async () => { },
showUnlockPrompt: false,
setShowUnlockPrompt: () => { },
signUserAction: async () => Promise.reject('Not initialized'),
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
@@ -55,6 +57,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
initializeIdentity,
unlockIdentity: unlockIdentityHook,
clearIdentity,
signUserAction,
} = useUserIdentity();
const checkAdmin = async () => {
@@ -67,7 +70,32 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
const [showUnlockPrompt, _setShowUnlockPrompt] = useState(false);
const [onUnlockCallback, setOnUnlockCallback] = useState<(() => void) | null>(null);
const setShowUnlockPrompt = (show: boolean, onSuccess?: () => void) => {
_setShowUnlockPrompt(show);
if (show && onSuccess) {
setOnUnlockCallback(() => onSuccess);
} else if (!show) {
// If hiding without success (cancel), clear callback ???
// Actually unlockIdentity handles success case.
// If explicit hide (cancel), we should probably clear it.
// But unlockIdentity calls setShowUnlockPrompt(false) on success too.
// So we handle callback execution in unlockIdentity,
// and clearing in unlockIdentity OR here if it wasn't executed?
// Let's rely on unlockIdentity to execute and clear.
// If just closing dialog (cancel), we clear it.
// But we don't know if this call is from cancel or success?
// unlockIdentity calls this.
}
};
// Clear callback on close if it wasn't executed?
// It's safer to clear it when closing prompt to avoid stale callbacks.
// But unlockIdentity calls setShowUnlockPrompt(false) AFTER executing.
// So:
/**
* Unlock the user's identity with their password
@@ -87,6 +115,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
targetUser.publicKey
);
// Execute queued callback if exists
if (onUnlockCallback) {
try {
onUnlockCallback();
} catch (e) {
console.error('Error executing unlock callback:', e);
}
setOnUnlockCallback(null);
}
setShowUnlockPrompt(false); // Close prompt on success
};
@@ -111,6 +148,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Clear the user's identity (private key from localStorage)
clearIdentity();
setShowUnlockPrompt(false);
setOnUnlockCallback(null);
// Clear the user state
setUser(null);
@@ -172,6 +210,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout,
showUnlockPrompt,
setShowUnlockPrompt,
signUserAction,
}}>
{children}
</AuthContext.Provider>
+18 -2
View File
@@ -23,21 +23,37 @@ export async function upsertRemoteUser(profile: RemoteProfile) {
if (existing) {
// Update metadata if changed
let avatarUrl = profile.avatarUrl || existing.avatarUrl;
// If still no avatar, generate one and save it "permanently"
if (!avatarUrl) {
const { generateAndUploadAvatar } = await import('@/lib/auth/avatar');
avatarUrl = await generateAndUploadAvatar(profile.handle);
}
await db.update(users)
.set({
displayName: profile.displayName || existing.displayName,
avatarUrl: profile.avatarUrl || existing.avatarUrl,
avatarUrl: avatarUrl,
isBot: profile.isBot ?? existing.isBot,
updatedAt: new Date(),
})
.where(eq(users.id, existing.id));
} else {
let avatarUrl = profile.avatarUrl;
// If no avatar provided, generate one
if (!avatarUrl) {
const { generateAndUploadAvatar } = await import('@/lib/auth/avatar');
avatarUrl = await generateAndUploadAvatar(profile.handle);
}
// Create new placeholder user
await db.insert(users).values({
did: profile.did,
handle: profile.handle, // user@domain
displayName: profile.displayName || profile.handle,
avatarUrl: profile.avatarUrl || null,
avatarUrl: avatarUrl,
isBot: profile.isBot || false,
publicKey: '', // We don't necessarily have their public key yet, but DMs often do
// Note: nodeId is null for remote placeholders unless we specifically link it