Compare commits
6 Commits
d1ce5d0951
...
13cdb605ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13cdb605ca | ||
|
|
45ab939b7f | ||
|
|
23035b6ab4 | ||
|
|
3f267e3b13 | ||
|
|
be99a02e78 | ||
|
|
a8d79cf138 |
@@ -288,6 +288,13 @@ export const roomSummaries = derived(rooms, ($rooms): RoomSummary[] => {
|
||||
return summaries;
|
||||
});
|
||||
|
||||
/**
|
||||
* Total unread count across all rooms (for nav badge)
|
||||
*/
|
||||
export const totalUnreadCount = derived(roomSummaries, ($summaries): number => {
|
||||
return $summaries.reduce((sum, room) => sum + (room.isSpace ? 0 : room.unreadCount), 0);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Messages
|
||||
// ============================================================================
|
||||
|
||||
@@ -613,6 +613,50 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
matrix_credentials: {
|
||||
Row: {
|
||||
access_token: string
|
||||
created_at: string
|
||||
device_id: string | null
|
||||
homeserver_url: string
|
||||
id: string
|
||||
matrix_user_id: string
|
||||
org_id: string
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
access_token: string
|
||||
created_at?: string
|
||||
device_id?: string | null
|
||||
homeserver_url: string
|
||||
id?: string
|
||||
matrix_user_id: string
|
||||
org_id: string
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
access_token?: string
|
||||
created_at?: string
|
||||
device_id?: string | null
|
||||
homeserver_url?: string
|
||||
id?: string
|
||||
matrix_user_id?: string
|
||||
org_id?: string
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "matrix_credentials_org_id_fkey"
|
||||
columns: ["org_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "organizations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
org_google_calendars: {
|
||||
Row: {
|
||||
calendar_id: string
|
||||
@@ -748,6 +792,13 @@ export type Database = {
|
||||
referencedRelation: "org_roles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "org_members_user_id_profiles_fk"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
org_roles: {
|
||||
@@ -803,6 +854,7 @@ export type Database = {
|
||||
created_at: string | null
|
||||
icon_url: string | null
|
||||
id: string
|
||||
matrix_space_id: string | null
|
||||
name: string
|
||||
slug: string
|
||||
theme_color: string | null
|
||||
@@ -813,6 +865,7 @@ export type Database = {
|
||||
created_at?: string | null
|
||||
icon_url?: string | null
|
||||
id?: string
|
||||
matrix_space_id?: string | null
|
||||
name: string
|
||||
slug: string
|
||||
theme_color?: string | null
|
||||
@@ -823,6 +876,7 @@ export type Database = {
|
||||
created_at?: string | null
|
||||
icon_url?: string | null
|
||||
id?: string
|
||||
matrix_space_id?: string | null
|
||||
name?: string
|
||||
slug?: string
|
||||
theme_color?: string | null
|
||||
@@ -1148,7 +1202,7 @@ export const Constants = {
|
||||
},
|
||||
} as const
|
||||
|
||||
// ── Convenience type aliases ──────────────────────────
|
||||
// ── Convenience type aliases ─────────────────────────────────────────
|
||||
export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
type PublicTables = Database['public']['Tables']
|
||||
|
||||
@@ -1171,3 +1225,4 @@ export type Team = PublicTables['teams']['Row']
|
||||
export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row']
|
||||
export type ActivityLog = PublicTables['activity_log']['Row']
|
||||
export type UserPreferences = PublicTables['user_preferences']['Row']
|
||||
export type MatrixCredentials = PublicTables['matrix_credentials']['Row']
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { hasPermission, type Permission } from "$lib/utils/permissions";
|
||||
import { setContext } from "svelte";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
import { totalUnreadCount } from "$lib/stores/matrix";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
@@ -126,6 +127,7 @@
|
||||
href: `/${data.org.slug}/chat`,
|
||||
label: "Chat",
|
||||
icon: "chat",
|
||||
badge: $totalUnreadCount > 0 ? ($totalUnreadCount > 99 ? "99+" : String($totalUnreadCount)) : null,
|
||||
},
|
||||
// Settings requires settings.view or admin role
|
||||
...(canAccess("settings.view")
|
||||
@@ -223,6 +225,11 @@
|
||||
? 'opacity-0 max-w-0 overflow-hidden'
|
||||
: 'opacity-100 max-w-[200px]'}">{item.label}</span
|
||||
>
|
||||
{#if item.badge}
|
||||
<span class="ml-auto bg-primary text-background text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shrink-0">
|
||||
{item.badge}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -79,15 +79,39 @@
|
||||
: [],
|
||||
);
|
||||
|
||||
const filteredRooms = $derived(
|
||||
// All non-space rooms (exclude Space entries themselves from the list)
|
||||
const allRooms = $derived(
|
||||
$roomSummaries.filter((r) => !r.isSpace),
|
||||
);
|
||||
|
||||
// Org rooms: rooms that belong to any Space
|
||||
const orgRooms = $derived(
|
||||
allRooms.filter((r) => r.parentSpaceId && !r.isDirect),
|
||||
);
|
||||
|
||||
// DMs: direct messages (not tied to org)
|
||||
const dmRooms = $derived(
|
||||
allRooms.filter((r) => r.isDirect),
|
||||
);
|
||||
|
||||
// Other rooms: not in a space and not a DM
|
||||
const otherRooms = $derived(
|
||||
allRooms.filter((r) => !r.parentSpaceId && !r.isDirect),
|
||||
);
|
||||
|
||||
// Apply search filter across all sections
|
||||
const filterBySearch = (rooms: typeof allRooms) =>
|
||||
roomSearchQuery.trim()
|
||||
? $roomSummaries.filter(
|
||||
? rooms.filter(
|
||||
(room) =>
|
||||
room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) ||
|
||||
room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()),
|
||||
)
|
||||
: $roomSummaries,
|
||||
);
|
||||
: rooms;
|
||||
|
||||
const filteredOrgRooms = $derived(filterBySearch(orgRooms));
|
||||
const filteredDmRooms = $derived(filterBySearch(dmRooms));
|
||||
const filteredOtherRooms = $derived(filterBySearch(otherRooms));
|
||||
|
||||
const currentMembers = $derived(
|
||||
$selectedRoomId ? getRoomMembers($selectedRoomId) : [],
|
||||
@@ -140,6 +164,9 @@
|
||||
accessToken: credentials.accessToken,
|
||||
deviceId: credentials.deviceId || null,
|
||||
});
|
||||
|
||||
// Check if org has a Matrix Space, auto-create if not
|
||||
await ensureOrgSpace(credentials);
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to init Matrix client:", e);
|
||||
toasts.error("Failed to connect to chat. Please re-login.");
|
||||
@@ -149,6 +176,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOrgSpace(credentials: LoginCredentials) {
|
||||
try {
|
||||
const spaceRes = await fetch(`/api/matrix-space?org_id=${data.org.id}`);
|
||||
const spaceResult = await spaceRes.json();
|
||||
|
||||
if (!spaceResult.spaceId) {
|
||||
// No Space yet — create one using the user's credentials
|
||||
const createRes = await fetch("/api/matrix-space", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
org_id: data.org.id,
|
||||
action: "create",
|
||||
homeserver_url: credentials.homeserverUrl,
|
||||
access_token: credentials.accessToken,
|
||||
org_name: data.org.name,
|
||||
}),
|
||||
});
|
||||
const createResult = await createRes.json();
|
||||
if (createResult.spaceId) {
|
||||
toasts.success(`Organization space created`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to ensure org space:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMatrixLogin() {
|
||||
if (!matrixUsername.trim() || !matrixPassword.trim()) {
|
||||
toasts.error("Please enter username and password");
|
||||
@@ -438,31 +493,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room actions -->
|
||||
<div class="flex items-center justify-between px-3 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
Rooms {roomSearchQuery ? `(${filteredRooms.length})` : ""}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showCreateRoomModal = true)}
|
||||
title="Create room"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">add_circle</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room list -->
|
||||
<!-- Room list (sectioned) -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{#if filteredRooms.length === 0}
|
||||
{#if allRooms.length === 0}
|
||||
<p class="text-light/40 text-sm text-center py-8">
|
||||
{roomSearchQuery ? "No matching rooms" : "No rooms yet"}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each filteredRooms as room (room.roomId)}
|
||||
<!-- Org / Space Rooms -->
|
||||
{#if filteredOrgRooms.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">workspaces</span>
|
||||
Organization
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showCreateRoomModal = true)}
|
||||
title="Create room"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each filteredOrgRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
@@ -482,6 +537,88 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Direct Messages -->
|
||||
{#if filteredDmRooms.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">chat_bubble</span>
|
||||
Direct Messages
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showStartDMModal = true)}
|
||||
title="New DM"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each filteredDmRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => handleRoomSelect(room.roomId)}
|
||||
>
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#if room.unreadCount > 0}
|
||||
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Other Rooms (not in a space, not DMs) -->
|
||||
{#if filteredOtherRooms.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">tag</span>
|
||||
Rooms
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showCreateRoomModal = true)}
|
||||
title="Create room"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each filteredOtherRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => handleRoomSelect(room.roomId)}
|
||||
>
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#if room.unreadCount > 0}
|
||||
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Cast supabase to any to bypass typed client — matrix_credentials table
|
||||
// was added in migration 020 but types haven't been regenerated yet.
|
||||
// TODO: Remove casts after running `supabase gen types`
|
||||
const db = (supabase: any) => supabase;
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
@@ -17,7 +12,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await db(locals.supabase)
|
||||
const { data, error } = await locals.supabase
|
||||
.from('matrix_credentials')
|
||||
.select('homeserver_url, matrix_user_id, access_token, device_id')
|
||||
.eq('user_id', session.user.id)
|
||||
@@ -44,7 +39,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await db(locals.supabase)
|
||||
const { error } = await locals.supabase
|
||||
.from('matrix_credentials')
|
||||
.upsert(
|
||||
{
|
||||
@@ -76,7 +71,7 @@ export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await db(locals.supabase)
|
||||
const { error } = await locals.supabase
|
||||
.from('matrix_credentials')
|
||||
.delete()
|
||||
.eq('user_id', session.user.id)
|
||||
|
||||
168
src/routes/api/matrix-space/+server.ts
Normal file
168
src/routes/api/matrix-space/+server.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET: Retrieve the Matrix Space ID for an org
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const orgId = url.searchParams.get('org_id');
|
||||
if (!orgId) {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('organizations')
|
||||
.select('matrix_space_id')
|
||||
.eq('id', orgId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ spaceId: data?.matrix_space_id ?? null });
|
||||
};
|
||||
|
||||
/**
|
||||
* POST: Create a Matrix Space for an org, or link an existing one.
|
||||
*
|
||||
* Body options:
|
||||
* - { org_id, action: "create", homeserver_url, access_token, org_name }
|
||||
* Creates a new Space on the homeserver and stores the ID.
|
||||
* - { org_id, action: "link", space_id }
|
||||
* Links an existing Matrix Space ID to the org.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { org_id, action } = body;
|
||||
|
||||
if (!org_id || !action) {
|
||||
return json({ error: 'org_id and action are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
const { homeserver_url, access_token, org_name } = body;
|
||||
if (!homeserver_url || !access_token || !org_name) {
|
||||
return json({ error: 'homeserver_url, access_token, and org_name are required for create' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a Matrix Space via the Client-Server API
|
||||
const createRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: org_name,
|
||||
topic: `Organization space for ${org_name}`,
|
||||
visibility: 'private',
|
||||
creation_content: {
|
||||
type: 'm.space',
|
||||
},
|
||||
initial_state: [
|
||||
{
|
||||
type: 'm.room.guest_access',
|
||||
state_key: '',
|
||||
content: { guest_access: 'can_join' },
|
||||
},
|
||||
],
|
||||
power_level_content_override: {
|
||||
invite: 50,
|
||||
kick: 50,
|
||||
ban: 50,
|
||||
events_default: 0,
|
||||
state_default: 50,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const err = await createRes.json().catch(() => ({}));
|
||||
return json({ error: err.error || 'Failed to create Matrix Space' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { room_id: spaceId } = await createRes.json();
|
||||
|
||||
// Also create default #general room inside the space
|
||||
const generalRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'General',
|
||||
topic: 'General discussion',
|
||||
visibility: 'private',
|
||||
preset: 'private_chat',
|
||||
}),
|
||||
});
|
||||
|
||||
if (generalRes.ok) {
|
||||
const { room_id: generalRoomId } = await generalRes.json();
|
||||
|
||||
// Add #general as a child of the space
|
||||
await fetch(
|
||||
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(generalRoomId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
via: [new URL(homeserver_url).hostname],
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Store space ID in org record
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('organizations')
|
||||
.update({ matrix_space_id: spaceId })
|
||||
.eq('id', org_id);
|
||||
|
||||
if (updateError) {
|
||||
return json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ spaceId, created: true });
|
||||
} catch (e: any) {
|
||||
console.error('Failed to create Matrix Space:', e);
|
||||
return json({ error: e.message || 'Failed to create Matrix Space' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'link') {
|
||||
const { space_id } = body;
|
||||
if (!space_id) {
|
||||
return json({ error: 'space_id is required for link action' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('organizations')
|
||||
.update({ matrix_space_id: space_id })
|
||||
.eq('id', org_id);
|
||||
|
||||
if (updateError) {
|
||||
return json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ spaceId: space_id, linked: true });
|
||||
}
|
||||
|
||||
return json({ error: 'Invalid action. Use "create" or "link".' }, { status: 400 });
|
||||
};
|
||||
184
src/routes/api/matrix-space/members/+server.ts
Normal file
184
src/routes/api/matrix-space/members/+server.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST: Invite a user to the org's Matrix Space (and its child rooms).
|
||||
*
|
||||
* Body: { org_id, matrix_user_id, homeserver_url, access_token }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { org_id, matrix_user_id, homeserver_url, access_token } = body;
|
||||
|
||||
if (!org_id || !matrix_user_id || !homeserver_url || !access_token) {
|
||||
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get org's Matrix Space ID
|
||||
const { data: org } = await locals.supabase
|
||||
.from('organizations')
|
||||
.select('matrix_space_id')
|
||||
.eq('id', org_id)
|
||||
.single();
|
||||
|
||||
if (!org?.matrix_space_id) {
|
||||
return json({ error: 'Organization has no Matrix Space' }, { status: 404 });
|
||||
}
|
||||
|
||||
const spaceId = org.matrix_space_id;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Invite to the Space itself
|
||||
const inviteRes = await matrixInvite(homeserver_url, access_token, spaceId, matrix_user_id);
|
||||
if (!inviteRes.ok) {
|
||||
const err = await inviteRes.json().catch(() => ({}));
|
||||
// M_FORBIDDEN means already joined, which is fine
|
||||
if (err.errcode !== 'M_FORBIDDEN') {
|
||||
errors.push(`Space invite: ${err.error || 'failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also invite to all child rooms of the space
|
||||
try {
|
||||
const stateRes = await fetch(
|
||||
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (stateRes.ok) {
|
||||
const stateEvents = await stateRes.json();
|
||||
const childRoomIds = stateEvents
|
||||
.filter((e: any) => e.type === 'm.space.child' && e.content?.via)
|
||||
.map((e: any) => e.state_key);
|
||||
|
||||
for (const childRoomId of childRoomIds) {
|
||||
const childInvite = await matrixInvite(homeserver_url, access_token, childRoomId, matrix_user_id);
|
||||
if (!childInvite.ok) {
|
||||
const err = await childInvite.json().catch(() => ({}));
|
||||
if (err.errcode !== 'M_FORBIDDEN') {
|
||||
errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('Failed to fetch space children');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: errors.length === 0,
|
||||
invited: matrix_user_id,
|
||||
spaceId,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE: Kick a user from the org's Matrix Space (and its child rooms).
|
||||
*
|
||||
* Query: ?org_id=...&matrix_user_id=...&homeserver_url=...&access_token=...
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const org_id = url.searchParams.get('org_id');
|
||||
const matrix_user_id = url.searchParams.get('matrix_user_id');
|
||||
const homeserver_url = url.searchParams.get('homeserver_url');
|
||||
const access_token = url.searchParams.get('access_token');
|
||||
|
||||
if (!org_id || !matrix_user_id || !homeserver_url || !access_token) {
|
||||
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: org } = await locals.supabase
|
||||
.from('organizations')
|
||||
.select('matrix_space_id')
|
||||
.eq('id', org_id)
|
||||
.single();
|
||||
|
||||
if (!org?.matrix_space_id) {
|
||||
return json({ error: 'Organization has no Matrix Space' }, { status: 404 });
|
||||
}
|
||||
|
||||
const spaceId = org.matrix_space_id;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Kick from child rooms first, then from the space
|
||||
try {
|
||||
const stateRes = await fetch(
|
||||
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (stateRes.ok) {
|
||||
const stateEvents = await stateRes.json();
|
||||
const childRoomIds = stateEvents
|
||||
.filter((e: any) => e.type === 'm.space.child' && e.content?.via)
|
||||
.map((e: any) => e.state_key);
|
||||
|
||||
for (const childRoomId of childRoomIds) {
|
||||
const kickRes = await matrixKick(homeserver_url, access_token, childRoomId, matrix_user_id);
|
||||
if (!kickRes.ok) {
|
||||
const err = await kickRes.json().catch(() => ({}));
|
||||
if (err.errcode !== 'M_FORBIDDEN') {
|
||||
errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('Failed to fetch space children');
|
||||
}
|
||||
|
||||
// Kick from the space itself
|
||||
const kickRes = await matrixKick(homeserver_url, access_token, spaceId, matrix_user_id);
|
||||
if (!kickRes.ok) {
|
||||
const err = await kickRes.json().catch(() => ({}));
|
||||
if (err.errcode !== 'M_FORBIDDEN') {
|
||||
errors.push(`Space kick: ${err.error || 'failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: errors.length === 0,
|
||||
kicked: matrix_user_id,
|
||||
spaceId,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Helper: invite a user to a room
|
||||
async function matrixInvite(homeserver: string, token: string, roomId: string, userId: string) {
|
||||
return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: kick a user from a room
|
||||
async function matrixKick(homeserver: string, token: string, roomId: string, userId: string) {
|
||||
return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId, reason: 'Removed from organization' }),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@import 'highlight.js/styles/github-dark.css';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@@ -102,4 +103,68 @@
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; }
|
||||
.prose a { @apply text-primary underline; }
|
||||
.prose hr { @apply border-t border-dark my-4; }
|
||||
.prose img { @apply max-w-full rounded-sm; }
|
||||
.prose table { @apply w-full border-collapse my-2; }
|
||||
.prose th, .prose td { @apply border border-dark p-2 text-left; }
|
||||
.prose th { @apply bg-night font-semibold; }
|
||||
}
|
||||
|
||||
/* Chat: Inline Twemoji sizing */
|
||||
.twemoji-inline {
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
vertical-align: -0.2em;
|
||||
margin: 0 0.05em;
|
||||
}
|
||||
|
||||
/* Chat: Emoji-only messages show larger emojis */
|
||||
.emoji-only .twemoji-inline {
|
||||
width: 2.8em;
|
||||
height: 2.8em;
|
||||
vertical-align: -0.3em;
|
||||
margin: 0 0.075em;
|
||||
}
|
||||
|
||||
.twemoji {
|
||||
display: inline-block;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
/* Chat: Mention styles */
|
||||
.mention-ping {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
color: var(--color-primary);
|
||||
padding: 0 0.25em;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.mention-ping:hover {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mention-everyone {
|
||||
background-color: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mention-everyone:hover {
|
||||
background-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Chat: Message highlight animation for reply scroll */
|
||||
@keyframes message-highlight {
|
||||
0%, 100% { background-color: transparent; }
|
||||
50% { background-color: rgba(0, 163, 224, 0.2); }
|
||||
}
|
||||
|
||||
.message-highlight {
|
||||
animation: message-highlight 1s ease-in-out 2;
|
||||
}
|
||||
|
||||
2
supabase/migrations/021_org_matrix_space.sql
Normal file
2
supabase/migrations/021_org_matrix_space.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add Matrix Space ID to organizations for org <-> space mapping
|
||||
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS matrix_space_id TEXT;
|
||||
@@ -10,6 +10,13 @@ export default defineConfig({
|
||||
sveltekit(),
|
||||
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
|
||||
],
|
||||
optimizeDeps: {
|
||||
exclude: ['@matrix-org/matrix-sdk-crypto-wasm']
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [],
|
||||
external: ['@matrix-org/matrix-sdk-crypto-wasm']
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Reduce file-watcher overhead on Windows — ignore heavy dirs
|
||||
|
||||
Reference in New Issue
Block a user