feat: Implement identity lock screen for sensitive user actions and introduce avatar generation.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user