feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
50ac0c8f64
commit
7802eb2cf0
17 files changed
+4391
-1368
No files matched your search
+198
-55
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
APIKey,
|
||||
AuthStatus,
|
||||
DashboardStats,
|
||||
Document,
|
||||
DocumentBlock,
|
||||
@@ -10,10 +11,10 @@ import {
|
||||
Space,
|
||||
SystemSettings,
|
||||
Team,
|
||||
TeamMember,
|
||||
TaskList,
|
||||
Thought,
|
||||
User,
|
||||
UserInvite,
|
||||
} from './types';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
@@ -41,7 +42,11 @@ export function triggerHaptic(pattern: number | number[] = 15): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}, isRetry = false): Promise<T> {
|
||||
function ensureArray<T>(val: T[] | null | undefined): T[] {
|
||||
return Array.isArray(val) ? val : [];
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -54,7 +59,7 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 8000);
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 12000);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
@@ -68,23 +73,15 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (res.status === 401 && !isRetry && !path.includes('/auth/login') && !path.includes('/auth/quickstart')) {
|
||||
try {
|
||||
const qsRes = await fetch('/api/v1/auth/quickstart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (qsRes.ok) {
|
||||
const qsData = await qsRes.json();
|
||||
if (qsData.token) {
|
||||
setToken(qsData.token);
|
||||
return request<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to standard error handling
|
||||
}
|
||||
if (
|
||||
res.status === 401 &&
|
||||
!path.includes('/auth/login') &&
|
||||
!path.includes('/auth/register') &&
|
||||
!path.includes('/auth/setup') &&
|
||||
!path.includes('/auth/status')
|
||||
) {
|
||||
clearToken();
|
||||
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -102,10 +99,23 @@ async function request<T>(path: string, options: RequestInit = {}, isRetry = fal
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// --- AUTH & SETTINGS ---
|
||||
async quickStart(): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/quickstart', {
|
||||
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
return request<AuthStatus>('/api/v1/auth/status');
|
||||
},
|
||||
|
||||
async completeSetup(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
instanceName?: string;
|
||||
sharingMode?: 'open' | 'teams_only' | 'strict_private';
|
||||
allowRegistration?: boolean;
|
||||
createSampleData?: boolean;
|
||||
}): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setToken(res.token);
|
||||
return res;
|
||||
@@ -120,10 +130,15 @@ export const api = {
|
||||
return res;
|
||||
},
|
||||
|
||||
async register(email: string, password: string, displayName: string): Promise<{ token: string; user: User }> {
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
inviteCode?: string
|
||||
): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, displayName }),
|
||||
body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
|
||||
});
|
||||
setToken(res.token);
|
||||
return res;
|
||||
@@ -137,7 +152,10 @@ export const api = {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/auth/settings', {
|
||||
@@ -146,10 +164,25 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async testAlarmChannel(
|
||||
channel: 'email' | 'discord' | 'webhook',
|
||||
target?: string
|
||||
): Promise<{ status: string }> {
|
||||
return request<{ status: string }>('/api/v1/auth/alarms/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel, target: target || '' }),
|
||||
});
|
||||
},
|
||||
|
||||
async getStats(): Promise<DashboardStats> {
|
||||
return request<DashboardStats>('/api/v1/stats');
|
||||
},
|
||||
|
||||
async getUsersDirectory(): Promise<User[]> {
|
||||
const res = await request<User[]>('/api/v1/users/directory');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
// --- INSTANT CAPTURE & OFFLINE SYNC ---
|
||||
async captureThought(
|
||||
content: string,
|
||||
@@ -176,7 +209,7 @@ export const api = {
|
||||
}),
|
||||
});
|
||||
return thought;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Queue locally if offline
|
||||
const queue = getOfflineQueue();
|
||||
const offlineThought: Thought = {
|
||||
@@ -229,11 +262,13 @@ export const api = {
|
||||
|
||||
// --- THOUGHTS, TRIAGE & TASKS ---
|
||||
async getTriageDeck(): Promise<Thought[]> {
|
||||
return request<Thought[]>('/api/v1/triage/deck');
|
||||
const res = await request<Thought[]>('/api/v1/triage/deck');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async getThoughts(status = 'all'): Promise<Thought[]> {
|
||||
return request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
|
||||
const res = await request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async triageAction(
|
||||
@@ -260,7 +295,8 @@ export const api = {
|
||||
|
||||
async getTasks(taskListId?: string): Promise<Thought[]> {
|
||||
const query = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
|
||||
return request<Thought[]>(`/api/v1/tasks${query}`);
|
||||
const res = await request<Thought[]>(`/api/v1/tasks${query}`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async updateThought(
|
||||
@@ -291,7 +327,8 @@ export const api = {
|
||||
|
||||
// --- TASK LISTS ---
|
||||
async getTaskLists(): Promise<TaskList[]> {
|
||||
return request<TaskList[]>('/api/v1/task-lists');
|
||||
const res = await request<TaskList[]>('/api/v1/task-lists');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createTaskList(payload: {
|
||||
@@ -327,11 +364,21 @@ export const api = {
|
||||
|
||||
// --- WORKSPACE DOCUMENTS ---
|
||||
async getDocuments(): Promise<Document[]> {
|
||||
return request<Document[]>('/api/v1/documents');
|
||||
const res = await request<Document[]>('/api/v1/documents');
|
||||
return ensureArray(res).map((d) => ({
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
}));
|
||||
},
|
||||
|
||||
async getDocument(id: string): Promise<Document> {
|
||||
return request<Document>(`/api/v1/documents/${id}`);
|
||||
const d = await request<Document>(`/api/v1/documents/${id}`);
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async createDocument(payload: {
|
||||
@@ -342,10 +389,15 @@ export const api = {
|
||||
tags?: string[];
|
||||
isPinned?: boolean;
|
||||
}): Promise<Document> {
|
||||
return request<Document>('/api/v1/documents', {
|
||||
const d = await request<Document>('/api/v1/documents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async updateDocument(
|
||||
@@ -359,10 +411,15 @@ export const api = {
|
||||
isPinned?: boolean;
|
||||
}
|
||||
): Promise<Document> {
|
||||
return request<Document>(`/api/v1/documents/${id}`, {
|
||||
const d = await request<Document>(`/api/v1/documents/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
@@ -371,7 +428,11 @@ export const api = {
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
async getSpaces(): Promise<Space[]> {
|
||||
return request<Space[]>('/api/v1/spaces');
|
||||
const res = await request<Space[]>('/api/v1/spaces');
|
||||
return ensureArray(res).map((s) => ({
|
||||
...s,
|
||||
members: ensureArray(s.members),
|
||||
}));
|
||||
},
|
||||
|
||||
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
||||
@@ -389,18 +450,30 @@ export const api = {
|
||||
},
|
||||
|
||||
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
|
||||
return request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
||||
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
||||
return ensureArray(res).map((l) => ({
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
}));
|
||||
},
|
||||
|
||||
async getAllLists(): Promise<SharedList[]> {
|
||||
return request<SharedList[]>('/api/v1/lists');
|
||||
const res = await request<SharedList[]>('/api/v1/lists');
|
||||
return ensureArray(res).map((l) => ({
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
}));
|
||||
},
|
||||
|
||||
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||
return request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
|
||||
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, icon, listType }),
|
||||
});
|
||||
return {
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
};
|
||||
},
|
||||
|
||||
async deleteList(listId: string): Promise<void> {
|
||||
@@ -433,7 +506,14 @@ export const api = {
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
return request<Organization[]>('/api/v1/organizations');
|
||||
const res = await request<Organization[]>('/api/v1/organizations');
|
||||
return ensureArray(res).map((o) => ({
|
||||
...o,
|
||||
teams: ensureArray(o.teams).map((t) => ({
|
||||
...t,
|
||||
members: ensureArray(t.members),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
async createOrganization(name: string): Promise<Organization> {
|
||||
@@ -443,8 +523,13 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async deleteOrganization(id: string): Promise<void> {
|
||||
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getOrgTeams(orgId: string): Promise<Team[]> {
|
||||
return request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
|
||||
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
|
||||
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
||||
},
|
||||
|
||||
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
|
||||
@@ -454,14 +539,22 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getUserTeams(): Promise<Team[]> {
|
||||
return request<Team[]>('/api/v1/teams');
|
||||
async deleteTeam(teamId: string): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async addTeamMember(teamId: string, userId: string, role = 'editor'): Promise<void> {
|
||||
async getUserTeams(): Promise<Team[]> {
|
||||
const res = await request<Team[]>('/api/v1/teams');
|
||||
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
||||
},
|
||||
|
||||
async addTeamMember(
|
||||
teamId: string,
|
||||
payload: { userId?: string; email?: string; role: string }
|
||||
): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, role }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -469,12 +562,18 @@ export const api = {
|
||||
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- ADMIN PANEL ---
|
||||
// --- ADMIN & STAFF PANEL ---
|
||||
async adminGetUsers(): Promise<User[]> {
|
||||
return request<User[]>('/api/v1/admin/users');
|
||||
const res = await request<User[]>('/api/v1/admin/users');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async adminCreateUser(payload: { email: string; password: string; displayName: string; role: string }): Promise<User> {
|
||||
async adminCreateUser(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -483,7 +582,13 @@ export const api = {
|
||||
|
||||
async adminUpdateUser(
|
||||
id: string,
|
||||
patch: { displayName?: string; email?: string; role?: string; isActive?: boolean; discordWebhookUrl?: string }
|
||||
patch: {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
discordWebhookUrl?: string;
|
||||
}
|
||||
): Promise<User> {
|
||||
return request<User>(`/api/v1/admin/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -502,6 +607,26 @@ export const api = {
|
||||
await request(`/api/v1/admin/users/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async adminGetInvites(): Promise<UserInvite[]> {
|
||||
const res = await request<UserInvite[]>('/api/v1/admin/invites');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async adminCreateInvite(payload: {
|
||||
email?: string;
|
||||
role: string;
|
||||
teamId?: string;
|
||||
}): Promise<UserInvite> {
|
||||
return request<UserInvite>('/api/v1/admin/invites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async adminDeleteInvite(id: string): Promise<void> {
|
||||
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async adminGetSettings(): Promise<SystemSettings> {
|
||||
return request<SystemSettings>('/api/v1/admin/settings');
|
||||
},
|
||||
@@ -513,9 +638,17 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
|
||||
return request<{ status: string }>('/api/v1/admin/smtp/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ toEmail }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- API KEYS, WEBHOOKS & EXPORT ---
|
||||
async getAPIKeys(): Promise<APIKey[]> {
|
||||
return request<APIKey[]>('/api/v1/keys');
|
||||
const res = await request<APIKey[]>('/api/v1/keys');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createAPIKey(name: string): Promise<APIKey> {
|
||||
@@ -529,15 +662,25 @@ export const api = {
|
||||
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async testWebhookIngest(token: string, title: string, priority: PriorityLevel, tags: string[]): Promise<{ status: string; thought: Thought }> {
|
||||
return request<{ status: string; thought: Thought }>(`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, priority, tags }),
|
||||
});
|
||||
async testWebhookIngest(
|
||||
token: string,
|
||||
title: string,
|
||||
priority: PriorityLevel,
|
||||
tags: string[]
|
||||
): Promise<{ status: string; thought: Thought }> {
|
||||
return request<{ status: string; thought: Thought }>(
|
||||
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, priority, tags }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
|
||||
return request<{ thoughts: Thought[]; documents: Document[] }>(`/api/v1/search?q=${encodeURIComponent(q)}`);
|
||||
return request<{ thoughts: Thought[]; documents: Document[] }>(
|
||||
`/api/v1/search?q=${encodeURIComponent(q)}`
|
||||
);
|
||||
},
|
||||
|
||||
async exportAll(): Promise<any> {
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
||||
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type UserRole = 'admin' | 'team_admin' | 'member';
|
||||
|
||||
export interface AuthStatus {
|
||||
needsSetup: boolean;
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: 'admin' | 'member';
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserInvite {
|
||||
id: string;
|
||||
code: string;
|
||||
email?: string;
|
||||
role: UserRole;
|
||||
teamId?: string;
|
||||
createdBy: string;
|
||||
usedBy?: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface Thought {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -48,6 +70,7 @@ export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
teams?: Team[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -65,7 +88,7 @@ export interface TeamMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
role: 'owner' | 'editor' | 'viewer';
|
||||
role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
@@ -73,6 +96,13 @@ export interface SystemSettings {
|
||||
sharingMode: 'open' | 'teams_only' | 'strict_private';
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpUser?: string;
|
||||
smtpPass?: string;
|
||||
smtpFrom?: string;
|
||||
smtpTls?: boolean;
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
}
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
|
||||
Reference in new issue
Block a user