+
-
- Login Credentials
-
- Manage the login methods linked to your account.
-
-
-
-
-
Loading credentials...
-
+
+
+
+
+
![Profile Picture]()
+
+
+
+
+
+
+
+
+
+
+ Login Credentials
+
+ Manage the login methods linked to your account.
+
+
+
+
+
Loading credentials...
+
- Link another account
-
-
+
@@ -265,13 +122,27 @@ Link another account
currentProvider = data.credential ? data.credential.provider : null;
document.getElementById('name').value = p.name || '';
document.getElementById('email').value = p.email || '';
- document.getElementById('display-name').textContent = p.name || 'Anonymous';
+ document.getElementById('page-title').textContent = p.name || 'Anonymous';
document.getElementById('display-email').textContent = p.email || '';
+ const userId = p.id;
+ document.getElementById('user-id-text').textContent = `ID: ${userId}`;
+ document.getElementById('copy-id-btn').onclick = () => {
+ navigator.clipboard.writeText(userId).then(() => {
+ showToast('User ID copied to clipboard');
+ }).catch(err => {
+ console.error('Failed to copy: ', err);
+ });
+ };
+
if (p.picture) {
const img = document.getElementById('profile-picture');
img.src = p.picture;
img.style.display = 'block';
+ } else {
+ const img = document.getElementById('profile-picture');
+ img.src = '';
+ img.style.display = 'none';
}
const emailLabel = document.querySelector('label[for="email"]');
@@ -280,6 +151,10 @@ Link another account
emailLabel.textContent = `Email (from ${providerName})`;
}
+ if (data.account && (data.account.role === 1 || data.is_admin)) {
+ document.getElementById('nav-account-item').style.display = 'block';
+ }
+
document.getElementById('save-btn').disabled = true;
}
} catch (e) {
diff --git a/public/users/style.css b/public/users/style.css
new file mode 100644
index 0000000..9bf095f
--- /dev/null
+++ b/public/users/style.css
@@ -0,0 +1,457 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ font-family:
+ system-ui,
+ -apple-system,
+ sans-serif;
+ padding: 2rem;
+ margin: 0 auto;
+ background: #f9f9f9;
+}
+
+.main-layout, .header-area {
+ max-width: 1280px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.main-layout {
+ display: flex;
+ gap: 3rem;
+ margin-top: 2rem;
+}
+
+.sidebar {
+ width: 240px;
+ flex-shrink: 0;
+}
+
+.content-area {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.header-area {
+ margin-bottom: 2rem;
+ padding-left: calc(240px + 3rem);
+}
+
+@media (max-width: 768px) {
+ .main-layout {
+ flex-direction: column;
+ gap: 2rem;
+ }
+ .sidebar {
+ width: 100%;
+ }
+ .header-area {
+ padding-left: 0;
+ }
+}
+
+.nav-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.nav-item {
+ margin-bottom: 0.5rem;
+}
+
+.nav-link {
+ display: block;
+ padding: 0.75rem 1rem;
+ color: #555;
+ text-decoration: none;
+ border-radius: 6px;
+ transition: all 0.2s;
+ font-weight: 500;
+ font-size: 0.9rem;
+}
+
+.nav-link:hover {
+ background: #f0f0f0;
+ color: #1a73e8;
+}
+
+.nav-link.active {
+ color: #1a73e8;
+ font-weight: 600;
+ border-left: 3px solid #1a73e8;
+ border-radius: 0;
+ padding-left: calc(1rem - 3px);
+}
+
+h1.page-subtitle {
+ color: #666;
+ margin-bottom: 0.25rem;
+ font-size: 1.1rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05rem;
+ margin-top: 0;
+}
+
+.page-title {
+ font-size: 2.5rem;
+ font-weight: bold;
+ color: #333;
+ margin-bottom: 0.5rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+}
+
+.subtitle {
+ font-size: 0.75rem;
+ color: #888;
+ margin-bottom: 2rem;
+ font-family: monospace;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ max-width: 100%;
+}
+
+section {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ margin-bottom: 2rem;
+}
+
+.form-group {
+ margin-bottom: 1.5rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+ color: #555;
+}
+
+.form-group input,
+.form-group select {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ box-sizing: border-box;
+ font-size: 1rem;
+}
+
+.form-group input:disabled {
+ background: #f0f0f0;
+ color: #888;
+}
+
+button {
+ padding: 0.75rem 1.5rem;
+ background: #1a73e8;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 500;
+}
+
+button:hover {
+ background: #1557b0;
+}
+
+button.secondary-btn {
+ background: #fff;
+ color: #1a73e8;
+ border: 1px solid #1a73e8;
+}
+
+button.secondary-btn:hover {
+ background: #f8f9fa;
+}
+
+button:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+}
+
+#toast {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ padding: 1rem;
+ background: #333;
+ color: white;
+ border-radius: 4px;
+ opacity: 0;
+ transition: opacity 0.3s;
+ z-index: 10000;
+}
+
+.back-link {
+ display: inline-block;
+ margin-bottom: 1rem;
+ color: #1a73e8;
+ text-decoration: none;
+}
+
+.back-link:hover {
+ text-decoration: underline;
+}
+
+.remove-btn {
+ background: transparent;
+ color: #d93025;
+ border: 1px solid #d93025;
+ padding: 0.4rem 0.8rem;
+ font-size: 0.85rem;
+}
+
+.remove-btn:hover {
+ background: #fce8e6;
+}
+
+.remove-btn:disabled {
+ background: #fafafa;
+ border-color: #eee;
+ color: #999;
+ cursor: not-allowed;
+}
+
+.btn-link {
+ display: inline-block;
+ padding: 0.75rem 1rem;
+ background: white;
+ color: #1a73e8;
+ border: 1px solid #1a73e8;
+ border-radius: 4px;
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 0.875rem;
+ transition: background 0.2s;
+}
+
+.btn-link:hover {
+ background: #f8f9fa;
+}
+
+/* Profile specific */
+.avatar-section {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.avatar-large {
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 3px solid white;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.account-avatar-large {
+ width: 100px;
+ height: 100px;
+ border-radius: 8px;
+ object-fit: cover;
+ border: 3px solid white;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.credential-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ border: 1px solid #eee;
+ border-radius: 8px;
+ margin-bottom: 0.75rem;
+}
+
+.credential-item.active {
+ border-color: #1a73e8;
+ background-color: #e8f0fe;
+}
+
+.current-badge {
+ font-size: 0.75rem;
+ background: #1a73e8;
+ color: white;
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.75rem;
+ margin-left: 0.5rem;
+ font-weight: normal;
+}
+
+.credential-info {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.provider-icon {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.link-account-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border-radius: 4px;
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 0.875rem;
+ border: 1px solid #ddd;
+ color: #333;
+ transition: background 0.2s;
+}
+
+.link-account-btn.google:hover {
+ background: #f8f9fa;
+}
+
+.link-account-btn.twitch {
+ background: #9146FF;
+ color: white;
+ border-color: #9146FF;
+}
+
+.link-account-btn.twitch:hover {
+ background: #7d2ee6;
+}
+
+/* Accounts specific */
+.member-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ border-bottom: 1px solid #eee;
+}
+
+.member-item:last-child {
+ border-bottom: none;
+}
+
+.member-info {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.member-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ background: #f1f3f4;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #5f6368;
+}
+
+.member-avatar svg {
+ width: 20px;
+ height: 20px;
+}
+
+.member-details {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.member-name {
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+}
+
+.member-role {
+ width: 80px;
+ display: flex;
+ justify-content: center;
+}
+
+.role-badge {
+ font-size: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 1rem;
+ background: #f1f3f4;
+ color: #5f6368;
+ font-weight: 500;
+}
+
+.role-badge.admin {
+ background: #e8f0fe;
+ color: #1a73e8;
+}
+
+.role-select {
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ font-size: 0.85rem;
+ background: #fff;
+}
+
+.role-select:disabled {
+ background: #f1f3f4;
+ color: #5f6368;
+ border-color: transparent;
+ appearance: none;
+ -webkit-appearance: none;
+ cursor: default;
+}
+
+.id-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 25ch;
+}
+
+.copy-btn {
+ background: none;
+ border: none;
+ padding: 0.25rem;
+ cursor: pointer;
+ color: #1a73e8;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+
+.copy-btn:hover {
+ background: #f0f0f0;
+}
+
+.copy-btn svg {
+ width: 14px;
+ height: 14px;
+}
diff --git a/src/AccountDO.ts b/src/AccountDO.ts
index 4c17134..acc08fb 100644
--- a/src/AccountDO.ts
+++ b/src/AccountDO.ts
@@ -35,9 +35,26 @@ export class AccountDO extends DurableObject {
role INTEGER,
joined_at INTEGER
);
+
+ CREATE TABLE IF NOT EXISTS images (
+ key TEXT PRIMARY KEY,
+ value BLOB,
+ mime_type TEXT
+ );
`);
}
+ async getImage(key: string) {
+ const result = this.sql.exec('SELECT value, mime_type FROM images WHERE key = ?', key);
+ const row = result.next().value as any;
+ return row || null;
+ }
+
+ async storeImage(key: string, value: ArrayBuffer, mime_type: string) {
+ this.sql.exec('INSERT OR REPLACE INTO images (key, value, mime_type) VALUES (?, ?, ?)', key, value, mime_type);
+ return { success: true };
+ }
+
async getInfo() {
const result = this.sql.exec('SELECT key, value FROM account_info');
const info: Record = {};
@@ -52,7 +69,11 @@ export class AccountDO extends DurableObject {
try {
this.ctx.storage.transactionSync(() => {
for (const [key, value] of Object.entries(data)) {
- this.sql.exec('INSERT OR REPLACE INTO account_info (key, value) VALUES (?, ?)', key, JSON.stringify(value));
+ let valToStore = value;
+ if (key === 'name' && typeof value === 'string') {
+ valToStore = value.substring(0, 50);
+ }
+ this.sql.exec('INSERT OR REPLACE INTO account_info (key, value) VALUES (?, ?)', key, JSON.stringify(valToStore));
}
});
return { success: true };
@@ -62,8 +83,30 @@ export class AccountDO extends DurableObject {
}
async getMembers() {
- const result = this.sql.exec('SELECT user_id, role, joined_at FROM members');
- return Array.from(result);
+ const result = Array.from(this.sql.exec('SELECT user_id, role, joined_at FROM members'));
+ const membersWithNames = await Promise.all(
+ result.map(async (m: any) => {
+ try {
+ const userStub = this.env.USER.get(this.env.USER.idFromString(m.user_id));
+ const profile = await userStub.getProfile();
+ const image = await userStub.getImage('avatar');
+
+ let picture = profile.picture || null;
+ if (image) {
+ picture = `/users/api/users/${m.user_id}/avatar`;
+ }
+
+ return {
+ ...m,
+ name: profile.name || 'Unknown User',
+ picture: picture,
+ };
+ } catch (e) {
+ return { ...m, name: 'Unknown User', picture: null };
+ }
+ }),
+ );
+ return membersWithNames;
}
async addMember(user_id: string, role: number) {
@@ -91,6 +134,20 @@ export class AccountDO extends DurableObject {
return { success: true };
}
+ async updateMemberRole(userId: string, role: number) {
+ this.sql.exec('UPDATE members SET role = ? WHERE user_id = ?', role, userId);
+
+ // Sync with User DO
+ try {
+ const userStub = this.env.USER.get(this.env.USER.idFromString(userId));
+ await userStub.addMembership(this.ctx.id.toString(), role, false);
+ } catch (e) {
+ console.error('Failed to sync membership role to UserDO', e);
+ }
+
+ return { success: true };
+ }
+
async removeMember(userId: string) {
this.sql.exec('DELETE FROM members WHERE user_id = ?', userId);
diff --git a/src/SystemDO.ts b/src/SystemDO.ts
index cf2a9e7..c97e521 100644
--- a/src/SystemDO.ts
+++ b/src/SystemDO.ts
@@ -160,6 +160,8 @@ export class SystemDO extends DurableObject {
async registerAccount(data: { id?: string; name: string; status?: string; plan?: string; ownerId?: string }) {
let accountIdStr = data.id;
+ const accountName = (data.name || '').substring(0, 50);
+
if (!accountIdStr) {
const id = this.env.ACCOUNT.newUniqueId();
accountIdStr = id.toString();
@@ -167,7 +169,7 @@ export class SystemDO extends DurableObject {
// Initialize AccountDO
const stub = this.env.ACCOUNT.get(id);
await stub.updateInfo({
- name: data.name,
+ name: accountName,
});
// If owner provided, add them as ADMIN
@@ -181,7 +183,7 @@ export class SystemDO extends DurableObject {
this.sql.exec(
'INSERT OR REPLACE INTO accounts (id, name, status, plan, member_count, created_at) VALUES (?, ?, ?, ?, ?, ?)',
accountIdStr,
- data.name,
+ accountName,
data.status || 'active',
data.plan || 'free',
data.ownerId ? 1 : 0,
@@ -215,10 +217,15 @@ export class SystemDO extends DurableObject {
}
async updateAccount(accountId: string, data: any) {
+ const sanitizedData = { ...data };
+ if (sanitizedData.name !== undefined) {
+ sanitizedData.name = sanitizedData.name.substring(0, 50);
+ }
+
// Update AccountDO
try {
const stub = this.env.ACCOUNT.get(this.env.ACCOUNT.idFromString(accountId));
- await stub.updateInfo(data);
+ await stub.updateInfo(sanitizedData);
} catch (e) {
console.error('Failed to update AccountDO', e);
}
@@ -227,9 +234,9 @@ export class SystemDO extends DurableObject {
const updates: string[] = [];
const args: any[] = [];
- if (data.name !== undefined) {
+ if (sanitizedData.name !== undefined) {
updates.push('name = ?');
- args.push(data.name);
+ args.push(sanitizedData.name);
}
if (data.status !== undefined) {
updates.push('status = ?');
diff --git a/src/index.ts b/src/index.ts
index f8b0438..0040baa 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -89,6 +89,26 @@ export default {
if (apiPath === '/me/accounts/switch' && request.method === 'POST') {
return handleSwitchAccount(request, env, cookieManager);
}
+
+ if (apiPath.startsWith('/me/accounts/')) {
+ const parts = apiPath.split('/');
+ if (parts.length === 4) {
+ return handleAccountDetails(request, env, parts[3], cookieManager);
+ }
+ if (parts.length === 5 && parts[4] === 'avatar') {
+ return handleAccountImage(request, env, parts[3], 'avatar', cookieManager);
+ }
+ if (parts.length >= 5 && parts[4] === 'members') {
+ return handleAccountMembers(request, env, parts[3], parts.slice(5), cookieManager);
+ }
+ }
+
+ if (apiPath.startsWith('/users/') && apiPath.endsWith('/avatar')) {
+ const parts = apiPath.split('/');
+ if (parts.length === 4) {
+ return handleUserImage(request, env, parts[2], 'avatar', cookieManager);
+ }
+ }
}
if (url.pathname === usersPath + 'logout') {
@@ -293,6 +313,106 @@ function isAdmin(user: any, env: StartupAPIEnv): boolean {
);
}
+async function handleAccountMembers(
+ request: Request,
+ env: StartupAPIEnv,
+ accountId: string,
+ extraParts: string[],
+ cookieManager: CookieManager,
+): Promise {
+ const user = await getUserFromSession(request, env, cookieManager);
+ if (!user) return new Response('Unauthorized', { status: 401 });
+
+ const userStub = env.USER.get(env.USER.idFromString(user.id));
+ const memberships = await userStub.getMemberships();
+ const membership = memberships.find((m: any) => m.account_id === accountId);
+
+ const isAccountAdmin = membership && membership.role === AccountDO.ROLE_ADMIN;
+ const isSysAdmin = isAdmin(user, env);
+
+ if (!isAccountAdmin && !isSysAdmin) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId));
+
+ if (extraParts.length === 0) {
+ if (request.method === 'GET') {
+ return Response.json(await accountStub.getMembers());
+ }
+ if (request.method === 'POST') {
+ const { user_id, role } = (await request.json()) as { user_id: string; role: number };
+ return Response.json(await accountStub.addMember(user_id, role));
+ }
+ } else if (extraParts.length === 1) {
+ const userIdToManage = extraParts[0];
+ if (request.method === 'DELETE') {
+ if (userIdToManage === user.id) {
+ return new Response('Cannot remove yourself', { status: 400 });
+ }
+ return Response.json(await accountStub.removeMember(userIdToManage));
+ }
+ if (request.method === 'PATCH') {
+ const { role } = (await request.json()) as { role: number };
+ if (userIdToManage === user.id && role !== AccountDO.ROLE_ADMIN) {
+ return new Response('Cannot demote yourself', { status: 400 });
+ }
+ return Response.json(await accountStub.updateMemberRole(userIdToManage, role));
+ }
+ }
+
+ return new Response('Not Found', { status: 404 });
+}
+
+async function handleAccountDetails(
+ request: Request,
+ env: StartupAPIEnv,
+ accountId: string,
+ cookieManager: CookieManager,
+): Promise {
+ const user = await getUserFromSession(request, env, cookieManager);
+ if (!user) return new Response('Unauthorized', { status: 401 });
+
+ const userStub = env.USER.get(env.USER.idFromString(user.id));
+ const memberships = await userStub.getMemberships();
+ const membership = memberships.find((m: any) => m.account_id === accountId);
+
+ const isAccountAdmin = membership && membership.role === AccountDO.ROLE_ADMIN;
+ const isSysAdmin = isAdmin(user, env);
+
+ if (!isAccountAdmin && !isSysAdmin) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId));
+
+ if (request.method === 'POST') {
+ const data = await request.json() as any;
+ const result = await accountStub.updateInfo(data);
+
+ // Sync with SystemDO index if name changed
+ if (data.name) {
+ try {
+ const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
+ await systemStub.updateAccount(accountId, { name: data.name });
+ } catch (e) {
+ console.error('Failed to sync account name to SystemDO', e);
+ }
+ }
+ return Response.json(result);
+ }
+
+ const info = await accountStub.getInfo();
+ const billing = await accountStub.getBillingInfo();
+
+ return Response.json({
+ ...info,
+ id: accountId,
+ role: membership ? membership.role : null,
+ billing,
+ });
+}
+
async function handleMe(
request: Request,
env: StartupAPIEnv,
@@ -322,6 +442,14 @@ async function handleMe(
if (!data.valid) return Response.json(data, { status: 401 });
+ const profile = { ...data.profile };
+ const image = await userStub.getImage('avatar');
+ if (image) {
+ const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH;
+ profile.picture = usersPath + 'me/avatar';
+ }
+
+ data.profile = profile;
data.is_admin = isAdmin({ id: doId, ...data.profile }, env);
data.is_impersonated = !!cookies['backup_session_id'];
@@ -487,6 +615,28 @@ async function handleMeImage(
return Response.json({ success: true });
}
+ return handleUserImage(request, env, doId, type, cookieManager);
+ } catch (e: any) {
+ console.error('[handleMeImage] Error:', e.message, e.stack);
+ return new Response('Error fetching image: ' + e.message, { status: 500 });
+ }
+}
+
+async function handleUserImage(
+ request: Request,
+ env: StartupAPIEnv,
+ userId: string,
+ type: string,
+ cookieManager: CookieManager,
+): Promise {
+ // Public access to user avatars (if we want them to be public in member lists)
+ // Or we could check if current user has permission to see it.
+ // For now, let's make it public if you know the ID.
+
+ try {
+ const id = env.USER.idFromString(userId);
+ const stub = env.USER.get(id);
+
const image = await stub.getImage(type);
if (!image) return new Response('Not Found', { status: 404 });
return new Response(image.value, { headers: { 'Content-Type': image.mime_type } });
@@ -495,6 +645,57 @@ async function handleMeImage(
}
}
+async function handleAccountImage(
+ request: Request,
+ env: StartupAPIEnv,
+ accountId: string,
+ type: string,
+ cookieManager: CookieManager,
+): Promise {
+ const user = await getUserFromSession(request, env, cookieManager);
+ if (!user) return new Response('Unauthorized', { status: 401 });
+
+ const userStub = env.USER.get(env.USER.idFromString(user.id));
+ const memberships = await userStub.getMemberships();
+ const membership = memberships.find((m: any) => m.account_id === accountId);
+
+ // For viewing, we might allow any member to see account avatar
+ if (!membership && !isAdmin(user, env)) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accountId));
+
+ try {
+ if (request.method === 'PUT') {
+ // Only admins can upload
+ if (membership?.role !== AccountDO.ROLE_ADMIN && !isAdmin(user, env)) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ const contentType = request.headers.get('Content-Type');
+ if (!contentType || !contentType.startsWith('image/')) {
+ return new Response('Invalid image type', { status: 400 });
+ }
+
+ const blob = await request.arrayBuffer();
+ if (blob.byteLength > 1024 * 1024) {
+ return new Response('Image too large (max 1MB)', { status: 400 });
+ }
+
+ await accountStub.storeImage(type, blob, contentType);
+ return Response.json({ success: true });
+ }
+
+ const image = await accountStub.getImage(type);
+ if (!image) return new Response('Not Found', { status: 404 });
+ return new Response(image.value, { headers: { 'Content-Type': image.mime_type } });
+ } catch (e: any) {
+ console.error('[handleAccountImage] Error:', e.message, e.stack);
+ return new Response('Error handling account image: ' + e.message, { status: 500 });
+ }
+}
+
async function handleLogout(
request: Request,
env: StartupAPIEnv,
diff --git a/test/membership.spec.ts b/test/membership.spec.ts
new file mode 100644
index 0000000..0508b6a
--- /dev/null
+++ b/test/membership.spec.ts
@@ -0,0 +1,195 @@
+import { env, SELF } from 'cloudflare:test';
+import { describe, it, expect, beforeEach } from 'vitest';
+import { CookieManager } from '../src/CookieManager';
+
+describe('Permission Enforcement', () => {
+ const cookieManager = new CookieManager(env.SESSION_SECRET);
+
+ async function createSession(userIdStr: string) {
+ const userStub = env.USER.get(env.USER.idFromString(userIdStr));
+ const { sessionId } = await userStub.createSession();
+ return `session_id=${await cookieManager.encrypt(`${sessionId}:${userIdStr}`)}`;
+ }
+
+ async function setupAccount(name: string) {
+ const accId = env.ACCOUNT.newUniqueId();
+ const accIdStr = accId.toString();
+ const accStub = env.ACCOUNT.get(accId);
+ await accStub.updateInfo({ name });
+ return { accId, accIdStr, accStub };
+ }
+
+ async function setupUser(role?: number, accIdStr?: string) {
+ const userId = env.USER.newUniqueId();
+ const userIdStr = userId.toString();
+ const cookie = await createSession(userIdStr);
+
+ if (accIdStr !== undefined && role !== undefined) {
+ const accStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(accIdStr));
+ await accStub.addMember(userIdStr, role);
+ }
+
+ return { userId, userIdStr, cookie };
+ }
+
+ it('Account Admin can perform all management tasks', async () => {
+ const { accIdStr } = await setupAccount('Admin Test Account');
+ const { cookie: adminCookie, userIdStr: adminIdStr } = await setupUser(1, accIdStr); // ROLE_ADMIN = 1
+ const { userIdStr: otherUserId } = await setupUser();
+
+ // 1. Can list members
+ const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, {
+ headers: { 'Cookie': adminCookie },
+ });
+ expect(listRes.status).toBe(200);
+
+ // 2. Can add members
+ const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, {
+ method: 'POST',
+ headers: { 'Cookie': adminCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user_id: otherUserId, role: 0 }),
+ });
+ expect(addRes.status).toBe(200);
+
+ // 3. Can update roles
+ const patchRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${otherUserId}`, {
+ method: 'PATCH',
+ headers: { 'Cookie': adminCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role: 1 }),
+ });
+ expect(patchRes.status).toBe(200);
+
+ // 4. Can update account name
+ const updateRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, {
+ method: 'POST',
+ headers: { 'Cookie': adminCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'New Name' }),
+ });
+ expect(updateRes.status).toBe(200);
+
+ // 5. Can remove others
+ const removeRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${otherUserId}`, {
+ method: 'DELETE',
+ headers: { 'Cookie': adminCookie },
+ });
+ expect(removeRes.status).toBe(200);
+
+ // 6. Cannot remove themselves
+ const selfRemoveRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${adminIdStr}`, {
+ method: 'DELETE',
+ headers: { 'Cookie': adminCookie },
+ });
+ expect(selfRemoveRes.status).toBe(400);
+
+ // 7. Cannot demote themselves
+ const selfDemoteRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${adminIdStr}`, {
+ method: 'PATCH',
+ headers: { 'Cookie': adminCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role: 0 }),
+ });
+ expect(selfDemoteRes.status).toBe(400);
+ });
+
+ it('Regular Member is forbidden from management tasks', async () => {
+ const { accIdStr } = await setupAccount('Member Test Account');
+ const { cookie: memberCookie } = await setupUser(0, accIdStr); // ROLE_USER = 0
+ const { userIdStr: otherUserId } = await setupUser();
+
+ // 1. Cannot list members
+ const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, {
+ headers: { 'Cookie': memberCookie },
+ });
+ expect(listRes.status).toBe(403);
+
+ // 2. Cannot add members
+ const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, {
+ method: 'POST',
+ headers: { 'Cookie': memberCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user_id: otherUserId, role: 0 }),
+ });
+ expect(addRes.status).toBe(403);
+
+ // 3. Cannot update roles
+ const patchRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members/${otherUserId}`, {
+ method: 'PATCH',
+ headers: { 'Cookie': memberCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role: 1 }),
+ });
+ expect(patchRes.status).toBe(403);
+
+ // 4. Cannot update account name
+ const updateRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, {
+ method: 'POST',
+ headers: { 'Cookie': memberCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'Hacker Name' }),
+ });
+ expect(updateRes.status).toBe(403);
+
+ // 5. Cannot get account details (including billing)
+ const detailsRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, {
+ headers: { 'Cookie': memberCookie },
+ });
+ expect(detailsRes.status).toBe(403);
+ });
+
+ it('Non-member is forbidden from management tasks', async () => {
+ const { accIdStr } = await setupAccount('Non-member Test Account');
+ const { cookie: nonMemberCookie } = await setupUser();
+ const { userIdStr: otherUserId } = await setupUser();
+
+ // 1. Cannot list members
+ const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, {
+ headers: { 'Cookie': nonMemberCookie },
+ });
+ expect(listRes.status).toBe(403);
+
+ // 2. Cannot add members
+ const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, {
+ method: 'POST',
+ headers: { 'Cookie': nonMemberCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user_id: otherUserId, role: 0 }),
+ });
+ expect(addRes.status).toBe(403);
+
+ // 3. Cannot update account name
+ const updateRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, {
+ method: 'POST',
+ headers: { 'Cookie': nonMemberCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'Hacker Name' }),
+ });
+ expect(updateRes.status).toBe(403);
+ });
+
+ it('System Admin can bypass all checks', async () => {
+ const { accIdStr } = await setupAccount('System Admin Test Account');
+
+ const adminIds = (env.ADMIN_IDS || '').split(',').map(id => id.trim());
+ const systemAdminId = env.USER.idFromName(adminIds[0]);
+ const systemAdminIdStr = systemAdminId.toString();
+ const systemAdminCookie = await createSession(systemAdminIdStr);
+
+ const { userIdStr: otherUserId } = await setupUser();
+
+ // 1. Can list members of any account
+ const listRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, {
+ headers: { 'Cookie': systemAdminCookie },
+ });
+ expect(listRes.status).toBe(200);
+
+ // 2. Can add members to any account
+ const addRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}/members`, {
+ method: 'POST',
+ headers: { 'Cookie': systemAdminCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user_id: otherUserId, role: 0 }),
+ });
+ expect(addRes.status).toBe(200);
+
+ // 3. Can update account name of any account
+ const updateRes = await SELF.fetch(`http://example.com/users/api/me/accounts/${accIdStr}`, {
+ method: 'POST',
+ headers: { 'Cookie': systemAdminCookie, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'System Updated Name' }),
+ });
+ expect(updateRes.status).toBe(200);
+ });
+});