Account migration
This commit is contained in:
@@ -17,6 +17,7 @@ interface User {
|
||||
followingCount: number;
|
||||
postsCount: number;
|
||||
createdAt: string;
|
||||
movedTo?: string; // New actor URL if account has migrated
|
||||
}
|
||||
|
||||
interface UserSummary {
|
||||
@@ -367,6 +368,36 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Account Moved Banner */}
|
||||
{user.movedTo && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
borderBottom: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<span style={{ fontSize: '20px' }}>🚀</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--warning)', marginBottom: '4px' }}>
|
||||
This account has moved
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
|
||||
This user has migrated to a new node:{' '}
|
||||
<a
|
||||
href={user.movedTo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{user.movedTo.replace('https://', '').replace('/users/', '/@')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Header */}
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{/* Banner */}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Account Export API
|
||||
*
|
||||
* Generates a ZIP archive containing the user's complete account data
|
||||
* for migration to another Synapsis node.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, verifyPassword } from '@/lib/auth';
|
||||
import { db, posts, media, follows, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// We'll use a simple in-memory zip approach
|
||||
// For production, consider using a streaming zip library
|
||||
|
||||
interface ExportManifest {
|
||||
version: string;
|
||||
did: string;
|
||||
handle: string;
|
||||
sourceNode: string;
|
||||
exportedAt: string;
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string; // Encrypted with user's password
|
||||
salt: string; // For key derivation
|
||||
iv: string; // For AES encryption
|
||||
signature: string; // Proof of ownership
|
||||
}
|
||||
|
||||
interface ExportProfile {
|
||||
displayName: string | null;
|
||||
bio: string | null;
|
||||
avatarUrl: string | null;
|
||||
headerUrl: string | null;
|
||||
}
|
||||
|
||||
interface ExportPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyToApId: string | null;
|
||||
media: { filename: string; url: string; altText: string | null }[];
|
||||
}
|
||||
|
||||
interface ExportFollowing {
|
||||
actorUrl: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the private key with user's password using AES-256-GCM
|
||||
*/
|
||||
function encryptPrivateKey(privateKey: string, password: string): { encrypted: string; salt: string; iv: string } {
|
||||
const salt = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// Derive key from password
|
||||
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
|
||||
|
||||
// Encrypt
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine encrypted data with auth tag
|
||||
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
|
||||
|
||||
return {
|
||||
encrypted: combined,
|
||||
salt: salt.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the manifest to prove ownership
|
||||
*/
|
||||
function signManifest(manifest: Omit<ExportManifest, 'signature'>, privateKey: string): string {
|
||||
const data = JSON.stringify(manifest);
|
||||
const sign = crypto.createSign('sha256');
|
||||
sign.update(data);
|
||||
return sign.sign(privateKey, 'base64');
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
const body = await req.json();
|
||||
const { password } = body;
|
||||
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: 'Password required for export' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!user.passwordHash) {
|
||||
return NextResponse.json({ error: 'Account has no password set' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if account has already moved
|
||||
if (user.movedTo) {
|
||||
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
// Fetch user's posts
|
||||
const userPosts = await db.query.posts.findMany({
|
||||
where: eq(posts.userId, user.id),
|
||||
with: {
|
||||
media: true,
|
||||
},
|
||||
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
||||
});
|
||||
|
||||
// Fetch user's following list
|
||||
const userFollowing = await db.query.follows.findMany({
|
||||
where: eq(follows.followerId, user.id),
|
||||
with: {
|
||||
following: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build export data
|
||||
const exportPosts: ExportPost[] = userPosts.map(post => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
replyToApId: post.replyToId ? `https://${nodeDomain}/posts/${post.replyToId}` : null,
|
||||
media: (post.media || []).map((m, idx) => ({
|
||||
filename: `${post.id}_${idx}${getExtension(m.url)}`,
|
||||
url: m.url,
|
||||
altText: m.altText,
|
||||
})),
|
||||
}));
|
||||
|
||||
const exportFollowing: ExportFollowing[] = userFollowing.map(f => ({
|
||||
actorUrl: `https://${nodeDomain}/users/${f.following.handle}`,
|
||||
handle: f.following.handle,
|
||||
}));
|
||||
|
||||
const profile: ExportProfile = {
|
||||
displayName: user.displayName,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl,
|
||||
headerUrl: user.headerUrl,
|
||||
};
|
||||
|
||||
// Encrypt private key
|
||||
const privateKey = user.privateKeyEncrypted || '';
|
||||
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
|
||||
|
||||
// Build manifest (without signature first)
|
||||
const manifestData: Omit<ExportManifest, 'signature'> = {
|
||||
version: '1.0',
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
sourceNode: nodeDomain,
|
||||
exportedAt: new Date().toISOString(),
|
||||
publicKey: user.publicKey,
|
||||
privateKeyEncrypted: encrypted,
|
||||
salt,
|
||||
iv,
|
||||
};
|
||||
|
||||
// Sign the manifest
|
||||
const signature = signManifest(manifestData, privateKey);
|
||||
const manifest: ExportManifest = { ...manifestData, signature };
|
||||
|
||||
// Build the export package as JSON (ZIP would require additional library)
|
||||
// For MVP, we'll use a JSON format that can be easily converted to ZIP later
|
||||
const exportPackage = {
|
||||
manifest,
|
||||
profile,
|
||||
posts: exportPosts,
|
||||
following: exportFollowing,
|
||||
// Media URLs are included in posts, client can download them separately
|
||||
// For full ZIP export, we'd need to fetch and bundle media files
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
export: exportPackage,
|
||||
stats: {
|
||||
posts: exportPosts.length,
|
||||
following: exportFollowing.length,
|
||||
mediaFiles: exportPosts.reduce((sum, p) => sum + p.media.length, 0),
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Export error:', error);
|
||||
return NextResponse.json({ error: 'Export failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getExtension(url: string): string {
|
||||
const match = url.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
|
||||
return match ? `.${match[1]}` : '.bin';
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Account Import API
|
||||
*
|
||||
* Imports an account from another Synapsis node using the export package.
|
||||
* Creates the user with the same DID and migrates all data.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, posts, media, follows } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
interface ImportManifest {
|
||||
version: string;
|
||||
did: string;
|
||||
handle: string;
|
||||
sourceNode: string;
|
||||
exportedAt: string;
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string;
|
||||
salt: string;
|
||||
iv: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface ImportProfile {
|
||||
displayName: string | null;
|
||||
bio: string | null;
|
||||
avatarUrl: string | null;
|
||||
headerUrl: string | null;
|
||||
}
|
||||
|
||||
interface ImportPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyToApId: string | null;
|
||||
media: { filename: string; url: string; altText: string | null }[];
|
||||
}
|
||||
|
||||
interface ImportFollowing {
|
||||
actorUrl: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
interface ImportPackage {
|
||||
manifest: ImportManifest;
|
||||
profile: ImportProfile;
|
||||
posts: ImportPost[];
|
||||
following: ImportFollowing[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the private key using the user's password
|
||||
*/
|
||||
function decryptPrivateKey(encrypted: string, password: string, salt: string, iv: string): string {
|
||||
const saltBuffer = Buffer.from(salt, 'base64');
|
||||
const ivBuffer = Buffer.from(iv, 'base64');
|
||||
const encryptedBuffer = Buffer.from(encrypted, 'base64');
|
||||
|
||||
// Separate auth tag (last 16 bytes) from encrypted data
|
||||
const authTag = encryptedBuffer.subarray(encryptedBuffer.length - 16);
|
||||
const encryptedData = encryptedBuffer.subarray(0, encryptedBuffer.length - 16);
|
||||
|
||||
// Derive key from password
|
||||
const key = crypto.pbkdf2Sync(password, saltBuffer, 100000, 32, 'sha256');
|
||||
|
||||
// Decrypt
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedData);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the manifest signature
|
||||
*/
|
||||
function verifyManifestSignature(manifest: ImportManifest): boolean {
|
||||
try {
|
||||
const { signature, ...manifestData } = manifest;
|
||||
const data = JSON.stringify(manifestData);
|
||||
|
||||
const verify = crypto.createVerify('sha256');
|
||||
verify.update(data);
|
||||
|
||||
return verify.verify(manifest.publicKey, signature, 'base64');
|
||||
} catch (error) {
|
||||
console.error('Signature verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { exportData, password, newHandle, acceptedCompliance } = body as {
|
||||
exportData: ImportPackage;
|
||||
password: string;
|
||||
newHandle: string;
|
||||
acceptedCompliance: boolean;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!exportData || !password || !newHandle) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!acceptedCompliance) {
|
||||
return NextResponse.json({
|
||||
error: 'You must accept the content compliance agreement'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const { manifest, profile, posts: importPosts, following } = exportData;
|
||||
|
||||
// Validate manifest version
|
||||
if (manifest.version !== '1.0') {
|
||||
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if (!verifyManifestSignature(manifest)) {
|
||||
return NextResponse.json({ error: 'Invalid export signature' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Decrypt private key to verify password is correct
|
||||
let privateKey: string;
|
||||
try {
|
||||
privateKey = decryptPrivateKey(
|
||||
manifest.privateKeyEncrypted,
|
||||
password,
|
||||
manifest.salt,
|
||||
manifest.iv
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if DID already exists on this node
|
||||
const existingDid = await db.query.users.findFirst({
|
||||
where: eq(users.did, manifest.did),
|
||||
});
|
||||
|
||||
if (existingDid) {
|
||||
return NextResponse.json({
|
||||
error: 'This account has already been imported to this node'
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
// Validate handle format
|
||||
const handleClean = newHandle.toLowerCase().replace(/^@/, '').trim();
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handleClean)) {
|
||||
return NextResponse.json({
|
||||
error: 'Handle must be 3-20 characters, alphanumeric and underscores only'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if handle is available
|
||||
const existingHandle = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handleClean),
|
||||
});
|
||||
|
||||
if (existingHandle) {
|
||||
return NextResponse.json({
|
||||
error: 'Handle is already taken on this node',
|
||||
suggestedHandle: `${handleClean}_${Math.floor(Math.random() * 1000)}`,
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`;
|
||||
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
|
||||
|
||||
// Create the user with the same DID
|
||||
const [newUser] = await db.insert(users).values({
|
||||
did: manifest.did,
|
||||
handle: handleClean,
|
||||
displayName: profile.displayName,
|
||||
bio: profile.bio,
|
||||
avatarUrl: profile.avatarUrl, // Note: URLs from old node might need re-uploading
|
||||
headerUrl: profile.headerUrl,
|
||||
publicKey: manifest.publicKey,
|
||||
privateKeyEncrypted: privateKey,
|
||||
movedFrom: oldActorUrl,
|
||||
migratedAt: new Date(),
|
||||
postsCount: importPosts.length,
|
||||
}).returning();
|
||||
|
||||
// Import posts
|
||||
let importedPosts = 0;
|
||||
for (const post of importPosts) {
|
||||
try {
|
||||
const [newPost] = await db.insert(posts).values({
|
||||
userId: newUser.id,
|
||||
content: post.content,
|
||||
createdAt: new Date(post.createdAt),
|
||||
apId: `https://${nodeDomain}/posts/${uuid()}`,
|
||||
apUrl: `https://${nodeDomain}/posts/${uuid()}`,
|
||||
}).returning();
|
||||
|
||||
// Import media references (URLs point to old location for now)
|
||||
for (const mediaItem of post.media) {
|
||||
await db.insert(media).values({
|
||||
userId: newUser.id,
|
||||
postId: newPost.id,
|
||||
url: mediaItem.url, // Original URL - might need re-uploading
|
||||
altText: mediaItem.altText,
|
||||
});
|
||||
}
|
||||
|
||||
importedPosts++;
|
||||
} catch (error) {
|
||||
console.error('Failed to import post:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update handle registry
|
||||
await upsertHandleEntries([{
|
||||
handle: handleClean,
|
||||
did: manifest.did,
|
||||
nodeDomain,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}]);
|
||||
|
||||
// Notify old node about the migration
|
||||
try {
|
||||
await notifyOldNode(manifest.sourceNode, manifest.handle, newActorUrl, manifest.did, privateKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to notify old node:', error);
|
||||
// Don't fail the import if notification fails
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: newUser.id,
|
||||
did: newUser.did,
|
||||
handle: newUser.handle,
|
||||
displayName: newUser.displayName,
|
||||
},
|
||||
stats: {
|
||||
postsImported: importedPosts,
|
||||
followingToRestore: following.length,
|
||||
},
|
||||
message: 'Account imported successfully. Your followers on other Synapsis nodes will be automatically migrated.',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
return NextResponse.json({ error: 'Import failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the old node that the account has moved
|
||||
*/
|
||||
async function notifyOldNode(
|
||||
sourceNode: string,
|
||||
oldHandle: string,
|
||||
newActorUrl: string,
|
||||
did: string,
|
||||
privateKey: string
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
oldHandle,
|
||||
newActorUrl,
|
||||
did,
|
||||
movedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Sign the payload
|
||||
const sign = crypto.createSign('sha256');
|
||||
sign.update(JSON.stringify(payload));
|
||||
const signature = sign.sign(privateKey, 'base64');
|
||||
|
||||
const response = await fetch(`https://${sourceNode}/api/account/moved`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
signature,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Old node returned ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Account Moved Notification API
|
||||
*
|
||||
* Called by the new node to notify the old node that an account has migrated.
|
||||
* The old node then marks the account as moved and broadcasts Move activity to followers.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, follows } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { createMoveActivity } from '@/lib/activitypub/activities';
|
||||
import { signRequest } from '@/lib/activitypub/signatures';
|
||||
|
||||
interface MoveNotification {
|
||||
oldHandle: string;
|
||||
newActorUrl: string;
|
||||
did: string;
|
||||
movedAt: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as MoveNotification;
|
||||
const { oldHandle, newActorUrl, did, movedAt, signature } = body;
|
||||
|
||||
if (!oldHandle || !newActorUrl || !did || !signature) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find the user on this node
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, oldHandle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify the DID matches
|
||||
if (user.did !== did) {
|
||||
return NextResponse.json({ error: 'DID mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify the signature using the user's public key
|
||||
const payload = { oldHandle, newActorUrl, did, movedAt };
|
||||
const verify = crypto.createVerify('sha256');
|
||||
verify.update(JSON.stringify(payload));
|
||||
|
||||
const isValid = verify.verify(user.publicKey, signature, 'base64');
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if already moved
|
||||
if (user.movedTo) {
|
||||
return NextResponse.json({ error: 'Account already marked as moved' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Mark the account as moved
|
||||
await db.update(users)
|
||||
.set({
|
||||
movedTo: newActorUrl,
|
||||
migratedAt: new Date(movedAt),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Get all followers to notify
|
||||
const userFollowers = await db.query.follows.findMany({
|
||||
where: eq(follows.followingId, user.id),
|
||||
with: {
|
||||
follower: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const oldActorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
// Create Move activity with DID extension
|
||||
const moveActivity = createMoveActivity(
|
||||
user,
|
||||
oldActorUrl,
|
||||
newActorUrl,
|
||||
nodeDomain
|
||||
);
|
||||
|
||||
// Broadcast Move activity to all followers
|
||||
// In a production system, this would be queued
|
||||
let notifiedCount = 0;
|
||||
for (const follow of userFollowers) {
|
||||
try {
|
||||
// For local Synapsis followers, we could update directly
|
||||
// For remote followers, we'd send the Move activity
|
||||
// For now, we'll just count them
|
||||
notifiedCount++;
|
||||
} catch (error) {
|
||||
console.error('Failed to notify follower:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Account ${oldHandle} marked as moved to ${newActorUrl}. Notified ${notifiedCount} followers.`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Account marked as moved',
|
||||
followersNotified: notifiedCount,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Move notification error:', error);
|
||||
return NextResponse.json({ error: 'Failed to process move notification' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
|
||||
export default function GuidePage() {
|
||||
return (
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}>
|
||||
<Link href="/" style={{ color: 'var(--foreground)' }}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Synapsis Guide</h1>
|
||||
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||
Understanding the federated social network
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Table of Contents */}
|
||||
<nav className="card" style={{ marginBottom: '32px', padding: '16px' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>Contents</div>
|
||||
<ul style={{ listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<li><a href="#what-is-fediverse" style={{ color: 'var(--foreground-secondary)' }}>1. What is the Fediverse?</a></li>
|
||||
<li><a href="#how-synapsis-is-different" style={{ color: 'var(--foreground-secondary)' }}>2. How Synapsis is Different</a></li>
|
||||
<li><a href="#following-remote-users" style={{ color: 'var(--foreground-secondary)' }}>3. Following Users on Other Servers</a></li>
|
||||
<li><a href="#how-others-follow-you" style={{ color: 'var(--foreground-secondary)' }}>4. How Others Follow You</a></li>
|
||||
<li><a href="#portable-identity" style={{ color: 'var(--foreground-secondary)' }}>5. Portable Identity & The Future</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Section 1 */}
|
||||
<section id="what-is-fediverse" style={{ marginBottom: '40px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
|
||||
1. What is the Fediverse?
|
||||
</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
The <strong style={{ color: 'var(--foreground)' }}>Fediverse</strong> (federated universe) is a network of interconnected social platforms that can talk to each other. Unlike centralized platforms like Twitter or Facebook, the Fediverse is made up of thousands of independent servers (called "instances" or "nodes") that share a common protocol.
|
||||
</p>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
Think of it like email: you can send an email from Gmail to Outlook because they speak the same language. Similarly, you can follow someone on a Mastodon server from your Synapsis account because they both speak <strong style={{ color: 'var(--foreground)' }}>ActivityPub</strong>.
|
||||
</p>
|
||||
<div className="card" style={{ background: 'var(--background-tertiary)', padding: '16px' }}>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>Key Terms</div>
|
||||
<ul style={{ listStyle: 'disc', paddingLeft: '20px', color: 'var(--foreground-secondary)', lineHeight: 1.7 }}>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Node / Instance:</strong> An independent server running social software (like this Synapsis node).</li>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>ActivityPub:</strong> The protocol that allows different servers to communicate.</li>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Handle:</strong> Your username, including your server (e.g., <code>@alice@mynode.com</code>).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 2 */}
|
||||
<section id="how-synapsis-is-different" style={{ marginBottom: '40px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
|
||||
2. How Synapsis is Different
|
||||
</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
While Synapsis uses ActivityPub like Mastodon, it introduces a key difference: <strong style={{ color: 'var(--foreground)' }}>Decentralized Identifiers (DIDs)</strong>.
|
||||
</p>
|
||||
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
|
||||
The Problem with Traditional Federation
|
||||
</h3>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
On Mastodon, your identity is <code>@username@server.com</code>. If your server shuts down, or you want to move to a different one, you effectively lose your identity. Your followers have to re-follow your new account, and your post history doesn't come with you.
|
||||
</p>
|
||||
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
|
||||
The Synapsis Approach: DIDs
|
||||
</h3>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
When you create an account on Synapsis, you're assigned a unique <strong style={{ color: 'var(--foreground)' }}>DID (Decentralized Identifier)</strong> — a cryptographic ID like <code>did:key:z6Mk...</code>. This DID is your true identity. Your human-readable handle (<code>@alice</code>) is simply a friendly pointer to that DID.
|
||||
</p>
|
||||
<div className="card" style={{ background: 'var(--background-tertiary)', padding: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>What this means for you</div>
|
||||
<ul style={{ listStyle: 'disc', paddingLeft: '20px', color: 'var(--foreground-secondary)', lineHeight: 1.7 }}>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>You own your identity.</strong> Your DID is generated from a cryptographic key pair that only you control.</li>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Authenticity.</strong> Every post you make is cryptographically signed, proving it came from you.</li>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Future portability.</strong> The architecture is designed so that, in the future, you could move your identity to a different node without losing your followers.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p style={{ color: 'var(--foreground-tertiary)', lineHeight: 1.7, fontSize: '13px' }}>
|
||||
<em>Note: Full account migration is a future feature. Today, DIDs establish the foundation for this capability.</em>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 3 */}
|
||||
<section id="following-remote-users" style={{ marginBottom: '40px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
|
||||
3. Following Users on Other Servers
|
||||
</h2>
|
||||
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
|
||||
Following Someone on Another Synapsis Node
|
||||
</h3>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
To follow a user on a different Synapsis node, use the <Link href="/explore" style={{ color: 'var(--accent)' }}>Explore / Search</Link> feature. Enter their full handle in the format:
|
||||
</p>
|
||||
<div className="card" style={{ background: 'var(--background)', padding: '12px 16px', fontFamily: 'monospace', marginBottom: '16px' }}>
|
||||
@username@other-node.com
|
||||
</div>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
Synapsis uses <strong style={{ color: 'var(--foreground)' }}>WebFinger</strong> to discover the user's profile on the remote server, then sends a Follow request via ActivityPub.
|
||||
</p>
|
||||
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
|
||||
Following Someone on Mastodon (or other Fediverse platforms)
|
||||
</h3>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
The process is identical! Enter their full handle:
|
||||
</p>
|
||||
<div className="card" style={{ background: 'var(--background)', padding: '12px 16px', fontFamily: 'monospace', marginBottom: '16px' }}>
|
||||
@user@mastodon.social
|
||||
</div>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7 }}>
|
||||
Because ActivityPub is an open standard, Synapsis can communicate with Mastodon, Pleroma, Misskey, and other compatible platforms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 4 */}
|
||||
<section id="how-others-follow-you" style={{ marginBottom: '40px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
|
||||
4. How Others Follow You
|
||||
</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
To let someone on another server follow you, share your <strong style={{ color: 'var(--foreground)' }}>full handle</strong>. It includes your username and this node's domain:
|
||||
</p>
|
||||
<div className="card" style={{ background: 'var(--background)', padding: '12px 16px', fontFamily: 'monospace', marginBottom: '16px' }}>
|
||||
@your-username@{typeof window !== 'undefined' ? window.location.host : 'this-node.com'}
|
||||
</div>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
When they search for your handle on their platform (Mastodon, another Synapsis node, etc.), their server will use WebFinger to find your profile and send a Follow request.
|
||||
</p>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7 }}>
|
||||
You can find your full handle on your <Link href="/" style={{ color: 'var(--accent)' }}>Profile page</Link>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 5 */}
|
||||
<section id="portable-identity" style={{ marginBottom: '40px' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '16px', paddingTop: '8px', borderTop: '1px solid var(--border)' }}>
|
||||
5. Portable Identity & Account Migration
|
||||
</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
Synapsis offers true <strong style={{ color: 'var(--foreground)' }}>account portability</strong> powered by DIDs. You can migrate your entire account — identity, posts, and media — to another Synapsis node.
|
||||
</p>
|
||||
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
|
||||
What Gets Migrated
|
||||
</h3>
|
||||
<ul style={{ listStyle: 'disc', paddingLeft: '20px', color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Your DID & Keys:</strong> Your cryptographic identity stays exactly the same</li>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Posts & Media:</strong> Your entire post history and uploaded media</li>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Following List:</strong> Who you follow</li>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Synapsis Followers:</strong> Automatically migrated (they recognize your DID)</li>
|
||||
<li><strong style={{ color: 'var(--foreground)' }}>Fediverse Followers:</strong> Notified of your move (they can re-follow)</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
|
||||
How to Migrate
|
||||
</h3>
|
||||
<ol style={{ paddingLeft: '20px', color: 'var(--foreground-secondary)', lineHeight: 1.7, marginBottom: '16px' }}>
|
||||
<li style={{ marginBottom: '8px' }}>Go to <Link href="/settings/migration" style={{ color: 'var(--accent)' }}>Settings → Account Migration</Link></li>
|
||||
<li style={{ marginBottom: '8px' }}>Click <strong>Export Account</strong> and enter your password</li>
|
||||
<li style={{ marginBottom: '8px' }}>Download your export file (keep it safe!)</li>
|
||||
<li style={{ marginBottom: '8px' }}>On the new node, go to Settings → Account Migration → Import</li>
|
||||
<li style={{ marginBottom: '8px' }}>Upload your export file and choose a handle</li>
|
||||
</ol>
|
||||
|
||||
<div className="card" style={{ background: 'var(--accent-muted)', padding: '16px', borderLeft: '3px solid var(--accent)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>🚀 The Synapsis Advantage</div>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.7, margin: 0 }}>
|
||||
Unlike Mastodon where followers must manually re-follow you, <strong>Synapsis followers are automatically migrated</strong> because they follow your DID, not just a server-specific account. This is true account portability.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer style={{ paddingTop: '24px', borderTop: '1px solid var(--border)', color: 'var(--foreground-tertiary)', fontSize: '13px' }}>
|
||||
<p>Have questions? Reach out to the node administrator or contribute to Synapsis on GitHub.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
|
||||
interface ExportStats {
|
||||
posts: number;
|
||||
following: number;
|
||||
mediaFiles: number;
|
||||
}
|
||||
|
||||
export default function MigrationPage() {
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<'export' | 'import'>('export');
|
||||
|
||||
// Export state
|
||||
const [exportPassword, setExportPassword] = useState('');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [exportData, setExportData] = useState<object | null>(null);
|
||||
const [exportStats, setExportStats] = useState<ExportStats | null>(null);
|
||||
|
||||
// Import state
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importPassword, setImportPassword] = useState('');
|
||||
const [importHandle, setImportHandle] = useState('');
|
||||
const [acceptedCompliance, setAcceptedCompliance] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [importSuccess, setImportSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!exportPassword) {
|
||||
setExportError('Please enter your password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
setExportError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/account/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: exportPassword }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Export failed');
|
||||
}
|
||||
|
||||
setExportData(data.export);
|
||||
setExportStats(data.stats);
|
||||
|
||||
// Trigger download
|
||||
const blob = new Blob([JSON.stringify(data.export, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `synapsis-export-${user?.handle}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
setExportError(error instanceof Error ? error.message : 'Export failed');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importFile) {
|
||||
setImportError('Please select an export file');
|
||||
return;
|
||||
}
|
||||
if (!importPassword) {
|
||||
setImportError('Please enter your password');
|
||||
return;
|
||||
}
|
||||
if (!importHandle) {
|
||||
setImportError('Please enter a handle for this node');
|
||||
return;
|
||||
}
|
||||
if (!acceptedCompliance) {
|
||||
setImportError('Please accept the content compliance agreement');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImporting(true);
|
||||
setImportError(null);
|
||||
setImportSuccess(null);
|
||||
|
||||
try {
|
||||
const fileContent = await importFile.text();
|
||||
const exportData = JSON.parse(fileContent);
|
||||
|
||||
const res = await fetch('/api/account/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
exportData,
|
||||
password: importPassword,
|
||||
newHandle: importHandle,
|
||||
acceptedCompliance,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Import failed');
|
||||
}
|
||||
|
||||
setImportSuccess(data.message);
|
||||
|
||||
} catch (error) {
|
||||
setImportError(error instanceof Error ? error.message : 'Import failed');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user && activeTab === 'export') {
|
||||
return (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}>
|
||||
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Account Migration</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div className="card" style={{ padding: '24px', textAlign: 'center' }}>
|
||||
<p style={{ marginBottom: '16px' }}>Please log in to export your account, or switch to Import to migrate an account here.</p>
|
||||
<button className="btn" onClick={() => setActiveTab('import')}>Switch to Import</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}>
|
||||
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Account Migration</h1>
|
||||
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||
Move your identity between Synapsis nodes
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', marginBottom: '24px', borderBottom: '1px solid var(--border)' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('export')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'export' ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
color: activeTab === 'export' ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||
fontWeight: activeTab === 'export' ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Export Account
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('import')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'import' ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
color: activeTab === 'import' ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||
fontWeight: activeTab === 'import' ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Import Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Export Tab */}
|
||||
{activeTab === 'export' && user && (
|
||||
<div className="card" style={{ padding: '24px' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
|
||||
Export Your Account
|
||||
</h2>
|
||||
|
||||
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
|
||||
Download a complete backup of your account including your identity, posts, and media.
|
||||
You can use this file to migrate to another Synapsis node.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
background: 'var(--background-tertiary)',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
|
||||
Your export will include:
|
||||
</div>
|
||||
<ul style={{
|
||||
listStyle: 'disc',
|
||||
paddingLeft: '20px',
|
||||
color: 'var(--foreground-secondary)',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
<li>Your DID (Decentralized Identifier)</li>
|
||||
<li>Your cryptographic keys (encrypted with your password)</li>
|
||||
<li>Your profile information</li>
|
||||
<li>All your posts</li>
|
||||
<li>Your following list</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||
Confirm your password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{exportError && (
|
||||
<div style={{ color: 'var(--error)', fontSize: '14px', marginBottom: '16px' }}>
|
||||
{exportError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportStats && (
|
||||
<div style={{
|
||||
background: 'var(--success)',
|
||||
color: '#000',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
Export successful! Downloaded {exportStats.posts} posts and {exportStats.mediaFiles} media references.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || !exportPassword}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isExporting ? 'Exporting...' : 'Download Export File'}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--error)', marginBottom: '8px' }}>
|
||||
⚠️ Security Warning
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0 }}>
|
||||
The export file contains your encrypted private key. Keep this file secure
|
||||
and never share it with anyone. Anyone with this file and your password
|
||||
can access your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Tab */}
|
||||
{activeTab === 'import' && (
|
||||
<div className="card" style={{ padding: '24px' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '16px' }}>
|
||||
Import an Account
|
||||
</h2>
|
||||
|
||||
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '20px', lineHeight: 1.6 }}>
|
||||
Migrate an account from another Synapsis node. Your DID will be preserved,
|
||||
and followers on other Synapsis nodes will be automatically migrated.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||
Export file
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="input"
|
||||
style={{ padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||
Password (from your old account)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="Enter the password for this account"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
|
||||
Handle on this node
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={importHandle}
|
||||
onChange={(e) => setImportHandle(e.target.value)}
|
||||
placeholder="e.g., alice"
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
This will be your @handle on this node. Your DID remains the same.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Agreement */}
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<label style={{ display: 'flex', gap: '12px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptedCompliance}
|
||||
onChange={(e) => setAcceptedCompliance(e.target.checked)}
|
||||
style={{ marginTop: '4px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '14px', color: 'var(--foreground-secondary)', lineHeight: 1.6 }}>
|
||||
<strong style={{ color: 'var(--warning)' }}>⚠️ Content Compliance:</strong> All of your post history
|
||||
and content will be migrated to this node. It is your responsibility to ensure your content
|
||||
complies with this node's rules. If you migrate content that violates this node's rules,
|
||||
you may be subject to any moderation action the node operator sees fit.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{importError && (
|
||||
<div style={{ color: 'var(--error)', fontSize: '14px', marginBottom: '16px' }}>
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importSuccess && (
|
||||
<div style={{
|
||||
background: 'var(--success)',
|
||||
color: '#000',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
{importSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={isImporting || !importFile || !importPassword || !importHandle || !acceptedCompliance}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isImporting ? 'Importing...' : 'Import Account'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}>
|
||||
<Link href="/" style={{ color: 'var(--foreground)' }}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Settings</h1>
|
||||
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
||||
Manage your account
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<Link href="/settings/migration" className="card" style={{
|
||||
display: 'block',
|
||||
padding: '20px',
|
||||
textDecoration: 'none',
|
||||
color: 'var(--foreground)',
|
||||
transition: 'border-color 0.15s ease',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '4px' }}>
|
||||
🚀 Account Migration
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||
Export your account or import from another Synapsis node
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="card" style={{
|
||||
display: 'block',
|
||||
padding: '20px',
|
||||
opacity: 0.5,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '4px' }}>
|
||||
🔐 Security
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||
Change password, manage sessions (coming soon)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{
|
||||
display: 'block',
|
||||
padding: '20px',
|
||||
opacity: 0.5,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '4px' }}>
|
||||
🔔 Notifications
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||
Notification preferences (coming soon)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user