@mdguggenbichler/slugbase-core 0.0.28 → 0.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/dist/routes/users.d.ts.map +1 -1
- package/backend/dist/routes/users.js +15 -1
- package/backend/dist/routes/users.js.map +1 -1
- package/backend/dist/utils/tenant.d.ts.map +1 -1
- package/backend/dist/utils/tenant.js +1 -4
- package/backend/dist/utils/tenant.js.map +1 -1
- package/frontend/src/components/AppSidebar.tsx +1 -1
- package/frontend/src/components/UserDropdown.tsx +1 -1
- package/frontend/src/components/sharing/ShareResourceDialog.tsx +152 -141
- package/frontend/src/components/ui/Button.tsx +20 -13
- package/frontend/src/components/ui/SharingField.tsx +4 -5
- package/frontend/src/locales/en.json +4 -1
- package/frontend/src/pages/Bookmarks.tsx +11 -9
- package/frontend/src/pages/Folders.tsx +1 -1
- package/frontend/src/pages/Profile.tsx +1 -1
- package/frontend/src/pages/Tags.tsx +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/routes/users.ts"],"names":[],"mappings":"AAQA,QAAA,MAAM,MAAM,4CAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/routes/users.ts"],"names":[],"mappings":"AAQA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAuJxB,eAAe,MAAM,CAAC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import { queryOne, execute } from '../db/index.js';
|
|
2
|
+
import { query, queryOne, execute } from '../db/index.js';
|
|
3
3
|
import { requireAuth } from '../middleware/auth.js';
|
|
4
4
|
import { validateEmail, normalizeEmail, validateLength, sanitizeString } from '../utils/validation.js';
|
|
5
5
|
import { v4 as uuidv4 } from 'uuid';
|
|
@@ -7,6 +7,20 @@ import crypto from 'crypto';
|
|
|
7
7
|
import { sendEmailVerificationEmail } from '../utils/email.js';
|
|
8
8
|
const router = Router();
|
|
9
9
|
router.use(requireAuth());
|
|
10
|
+
// List users for sharing (same tenant/org). No admin required. Used by sharing modal.
|
|
11
|
+
// Core: users table has no tenant_id; returns all users except current (self-hosted single-tenant).
|
|
12
|
+
router.get('/for-sharing', async (req, res) => {
|
|
13
|
+
const authReq = req;
|
|
14
|
+
try {
|
|
15
|
+
const userId = authReq.user.id;
|
|
16
|
+
const rows = await query('SELECT id, name, email FROM users WHERE id != ? ORDER BY name ASC, email ASC', [userId]);
|
|
17
|
+
const list = Array.isArray(rows) ? rows : (rows ? [rows] : []);
|
|
18
|
+
res.json(list);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
res.status(500).json({ error: error.message });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
10
24
|
// Get current user profile
|
|
11
25
|
router.get('/me', async (req, res) => {
|
|
12
26
|
const authReq = req;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"users.js","sourceRoot":"","sources":["../../src/routes/users.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"users.js","sourceRoot":"","sources":["../../src/routes/users.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/utils/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/utils/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAGhD"}
|
|
@@ -4,9 +4,6 @@ export function getDefaultTenantId() {
|
|
|
4
4
|
}
|
|
5
5
|
export function getTenantId(req) {
|
|
6
6
|
const tenantId = req.tenantId;
|
|
7
|
-
|
|
8
|
-
throw new Error('Missing tenantId on request');
|
|
9
|
-
}
|
|
10
|
-
return tenantId;
|
|
7
|
+
return tenantId ?? DEFAULT_TENANT_ID;
|
|
11
8
|
}
|
|
12
9
|
//# sourceMappingURL=tenant.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.js","sourceRoot":"","sources":["../../src/utils/tenant.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,iBAAiB,GAAG,SAAS,CAAC;AAE3C,MAAM,UAAU,kBAAkB;IAChC,OAAO,iBAAiB,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,GAAY;IACtC,MAAM,QAAQ,GAAI,GAAuC,CAAC,QAAQ,CAAC;IACnE,
|
|
1
|
+
{"version":3,"file":"tenant.js","sourceRoot":"","sources":["../../src/utils/tenant.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,iBAAiB,GAAG,SAAS,CAAC;AAE3C,MAAM,UAAU,kBAAkB;IAChC,OAAO,iBAAiB,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,GAAY;IACtC,MAAM,QAAQ,GAAI,GAAuC,CAAC,QAAQ,CAAC;IACnE,OAAO,QAAQ,IAAI,iBAAiB,CAAC;AACvC,CAAC"}
|
|
@@ -69,7 +69,7 @@ export default function AppSidebar({ user, version = null }: AppSidebarProps) {
|
|
|
69
69
|
pathname === rootActivePath + '/' ||
|
|
70
70
|
pathname === (pathBaseForActive || '/');
|
|
71
71
|
|
|
72
|
-
const showAdmin = user?.is_admin;
|
|
72
|
+
const showAdmin = !!(user?.is_admin);
|
|
73
73
|
const [adminOpen, setAdminOpen] = useState(() => {
|
|
74
74
|
if (typeof window === 'undefined') return true;
|
|
75
75
|
const stored = localStorage.getItem(SIDEBAR_ADMIN_OPEN_KEY);
|
|
@@ -31,7 +31,7 @@ export default function UserDropdown({ user }: UserDropdownProps) {
|
|
|
31
31
|
const prefix = (pathPrefixForLinks || '').replace(/\/+/g, '/') || '';
|
|
32
32
|
const { logout } = useAuth();
|
|
33
33
|
|
|
34
|
-
const showAdmin = user?.is_admin;
|
|
34
|
+
const showAdmin = !!(user?.is_admin);
|
|
35
35
|
|
|
36
36
|
if (!user) return null;
|
|
37
37
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import { useAuth } from '../../contexts/AuthContext';
|
|
4
3
|
import api from '../../api/client';
|
|
5
4
|
import {
|
|
6
5
|
Dialog,
|
|
@@ -12,15 +11,6 @@ import {
|
|
|
12
11
|
import { Separator } from '../ui/separator';
|
|
13
12
|
import { Input } from '../ui/input';
|
|
14
13
|
import { ScrollArea } from '../ui/scroll-area';
|
|
15
|
-
import { Popover, PopoverContent, PopoverAnchor, PopoverTrigger } from '../ui/popover';
|
|
16
|
-
import {
|
|
17
|
-
Command,
|
|
18
|
-
CommandEmpty,
|
|
19
|
-
CommandGroup,
|
|
20
|
-
CommandInput,
|
|
21
|
-
CommandItem,
|
|
22
|
-
CommandList,
|
|
23
|
-
} from '../ui/command';
|
|
24
14
|
import Button from '../ui/Button';
|
|
25
15
|
import Tooltip from '../ui/Tooltip';
|
|
26
16
|
import { useToast } from '../ui/Toast';
|
|
@@ -56,7 +46,6 @@ export default function ShareResourceDialog({
|
|
|
56
46
|
onSuccess,
|
|
57
47
|
}: ShareResourceDialogProps) {
|
|
58
48
|
const { t } = useTranslation();
|
|
59
|
-
const { user } = useAuth();
|
|
60
49
|
const { showToast } = useToast();
|
|
61
50
|
const [loading, setLoading] = useState(true);
|
|
62
51
|
const [saving, setSaving] = useState(false);
|
|
@@ -67,17 +56,16 @@ export default function ShareResourceDialog({
|
|
|
67
56
|
const [allUsers, setAllUsers] = useState<SharedUser[]>([]);
|
|
68
57
|
const [teams, setTeams] = useState<SharedTeam[]>([]);
|
|
69
58
|
const [emailInput, setEmailInput] = useState('');
|
|
70
|
-
const [
|
|
71
|
-
const [teamsPopoverOpen, setTeamsPopoverOpen] = useState(false);
|
|
59
|
+
const [peopleDropdownOpen, setPeopleDropdownOpen] = useState(false);
|
|
72
60
|
const [activeTab, setActiveTab] = useState<'people' | 'teams'>('people');
|
|
73
61
|
|
|
62
|
+
const MIN_CHARS_FOR_USER_DROPDOWN = 3;
|
|
63
|
+
|
|
74
64
|
const allowShareToTeams = teams.length > 0;
|
|
75
65
|
const allowShareToUsers = true;
|
|
76
66
|
|
|
77
|
-
const
|
|
78
|
-
if (!resourceId
|
|
79
|
-
setLoading(true);
|
|
80
|
-
setError(null);
|
|
67
|
+
const refreshResourceOnly = useCallback(async () => {
|
|
68
|
+
if (!resourceId) return;
|
|
81
69
|
try {
|
|
82
70
|
const endpoint = resourceType === 'bookmark' ? `/bookmarks/${resourceId}` : `/folders/${resourceId}`;
|
|
83
71
|
const res = await api.get(endpoint);
|
|
@@ -85,35 +73,56 @@ export default function ShareResourceDialog({
|
|
|
85
73
|
setSharedUsers(data.shared_users ?? []);
|
|
86
74
|
setSharedTeams(data.shared_teams ?? []);
|
|
87
75
|
setResourceData(resourceType === 'folder' ? { name: data.name, icon: data.icon } : null);
|
|
88
|
-
} catch
|
|
89
|
-
|
|
90
|
-
setError(err.response?.data?.error || t('common.error'));
|
|
91
|
-
showToast(t('common.error'), 'error');
|
|
92
|
-
} finally {
|
|
93
|
-
setLoading(false);
|
|
76
|
+
} catch {
|
|
77
|
+
// ignore
|
|
94
78
|
}
|
|
95
|
-
}, [resourceId, resourceType
|
|
79
|
+
}, [resourceId, resourceType]);
|
|
96
80
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
81
|
+
const loadAll = useCallback(async () => {
|
|
82
|
+
if (!resourceId || !isOpen) return;
|
|
83
|
+
setLoading(true);
|
|
84
|
+
setError(null);
|
|
85
|
+
const endpoint = resourceType === 'bookmark' ? `/bookmarks/${resourceId}` : `/folders/${resourceId}`;
|
|
86
|
+
|
|
87
|
+
const [resourceResult, usersResult, teamsResult] = await Promise.allSettled([
|
|
88
|
+
api.get(endpoint),
|
|
89
|
+
(async () => {
|
|
90
|
+
try {
|
|
91
|
+
const res = await api.get('/users/for-sharing');
|
|
92
|
+
return Array.isArray(res.data) ? res.data : [];
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
try {
|
|
95
|
+
const adminRes = await api.get('/admin/users');
|
|
96
|
+
const list = Array.isArray(adminRes.data) ? adminRes.data : [];
|
|
97
|
+
return list.filter((u: SharedUser) => u.id && u.email);
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})(),
|
|
103
|
+
api.get('/teams').then((r) => r.data).catch(() => []),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
if (resourceResult.status === 'fulfilled') {
|
|
107
|
+
const data = resourceResult.value.data;
|
|
108
|
+
setSharedUsers(data.shared_users ?? []);
|
|
109
|
+
setSharedTeams(data.shared_teams ?? []);
|
|
110
|
+
setResourceData(resourceType === 'folder' ? { name: data.name, icon: data.icon } : null);
|
|
111
|
+
} else {
|
|
112
|
+
setError(t('common.error'));
|
|
113
|
+
showToast(t('common.error'), 'error');
|
|
108
114
|
}
|
|
109
|
-
|
|
115
|
+
|
|
116
|
+
setAllUsers(usersResult.status === 'fulfilled' ? usersResult.value : []);
|
|
117
|
+
const teamsData = teamsResult.status === 'fulfilled' ? teamsResult.value : [];
|
|
118
|
+
setTeams(Array.isArray(teamsData) ? teamsData : teamsData != null ? [teamsData] : []);
|
|
119
|
+
|
|
120
|
+
setLoading(false);
|
|
121
|
+
}, [resourceId, resourceType, isOpen, t, showToast]);
|
|
110
122
|
|
|
111
123
|
useEffect(() => {
|
|
112
|
-
if (isOpen)
|
|
113
|
-
|
|
114
|
-
fetchUsersAndTeams();
|
|
115
|
-
}
|
|
116
|
-
}, [isOpen, fetchResource, fetchUsersAndTeams]);
|
|
124
|
+
if (isOpen) loadAll();
|
|
125
|
+
}, [isOpen, loadAll]);
|
|
117
126
|
|
|
118
127
|
async function updateShares(userIds: string[], teamIds: string[], shareAllTeams: boolean) {
|
|
119
128
|
setSaving(true);
|
|
@@ -130,7 +139,7 @@ export default function ShareResourceDialog({
|
|
|
130
139
|
payload.share_all_teams = shareAllTeams;
|
|
131
140
|
}
|
|
132
141
|
await api.put(endpoint, payload);
|
|
133
|
-
await
|
|
142
|
+
await refreshResourceOnly();
|
|
134
143
|
onSuccess();
|
|
135
144
|
showToast(t('common.success'), 'success');
|
|
136
145
|
} catch (err: any) {
|
|
@@ -156,7 +165,7 @@ export default function ShareResourceDialog({
|
|
|
156
165
|
const newUserIds = [...sharedUsers.map((u) => u.id), userId];
|
|
157
166
|
if (newUserIds.includes(userId)) return;
|
|
158
167
|
updateShares(newUserIds, sharedTeams.map((t) => t.id), false);
|
|
159
|
-
|
|
168
|
+
setPeopleDropdownOpen(false);
|
|
160
169
|
setEmailInput('');
|
|
161
170
|
}
|
|
162
171
|
|
|
@@ -175,14 +184,19 @@ export default function ShareResourceDialog({
|
|
|
175
184
|
const newTeamIds = [...sharedTeams.map((t) => t.id), teamId];
|
|
176
185
|
if (newTeamIds.includes(teamId)) return;
|
|
177
186
|
updateShares(sharedUsers.map((u) => u.id), newTeamIds, false);
|
|
178
|
-
setTeamsPopoverOpen(false);
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
const searchQuery = emailInput.trim().toLowerCase();
|
|
181
190
|
const filteredUsers = allUsers.filter((u) => {
|
|
182
|
-
if (!
|
|
183
|
-
|
|
184
|
-
|
|
191
|
+
if (!searchQuery) return false;
|
|
192
|
+
return (
|
|
193
|
+
u.email.toLowerCase().includes(searchQuery) ||
|
|
194
|
+
(u.name && u.name.toLowerCase().includes(searchQuery))
|
|
195
|
+
);
|
|
185
196
|
});
|
|
197
|
+
const usersAvailableToAdd = filteredUsers.filter((u) => !sharedUsers.some((su) => su.id === u.id));
|
|
198
|
+
const showUserDropdown =
|
|
199
|
+
searchQuery.length >= MIN_CHARS_FOR_USER_DROPDOWN && usersAvailableToAdd.length > 0;
|
|
186
200
|
|
|
187
201
|
const filteredTeams = teams.filter((t) => !sharedTeams.some((st) => st.id === t.id));
|
|
188
202
|
|
|
@@ -275,6 +289,11 @@ export default function ShareResourceDialog({
|
|
|
275
289
|
|
|
276
290
|
<div>
|
|
277
291
|
<h4 className="text-sm font-medium mb-3">{t('sharing.addAccess')}</h4>
|
|
292
|
+
{!loading && allUsers.length === 0 && teams.length === 0 && (
|
|
293
|
+
<p className="text-xs text-amber-600 dark:text-amber-500 mb-2" role="status">
|
|
294
|
+
{t('sharing.noUsersOrTeams')}
|
|
295
|
+
</p>
|
|
296
|
+
)}
|
|
278
297
|
{allowShareToTeams && allowShareToUsers ? (
|
|
279
298
|
<div className="flex gap-2 border-b mb-3">
|
|
280
299
|
<button
|
|
@@ -306,105 +325,97 @@ export default function ShareResourceDialog({
|
|
|
306
325
|
|
|
307
326
|
{allowShareToUsers && (activeTab === 'people' || !allowShareToTeams) && (
|
|
308
327
|
<div className="space-y-2">
|
|
309
|
-
<div className="
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
</Popover>
|
|
328
|
+
<div className="relative">
|
|
329
|
+
<div className="flex gap-2">
|
|
330
|
+
<Input
|
|
331
|
+
placeholder={t('admin.searchUsers')}
|
|
332
|
+
value={emailInput}
|
|
333
|
+
onChange={(e) => {
|
|
334
|
+
setEmailInput(e.target.value);
|
|
335
|
+
setPeopleDropdownOpen(true);
|
|
336
|
+
}}
|
|
337
|
+
onFocus={() => {
|
|
338
|
+
if (searchQuery.length >= MIN_CHARS_FOR_USER_DROPDOWN)
|
|
339
|
+
setPeopleDropdownOpen(true);
|
|
340
|
+
}}
|
|
341
|
+
onBlur={() => {
|
|
342
|
+
setTimeout(() => setPeopleDropdownOpen(false), 150);
|
|
343
|
+
}}
|
|
344
|
+
onKeyDown={(e) => {
|
|
345
|
+
if (e.key === 'Enter') {
|
|
346
|
+
e.preventDefault();
|
|
347
|
+
handleAddUserByEmail();
|
|
348
|
+
}
|
|
349
|
+
}}
|
|
350
|
+
className="flex-1"
|
|
351
|
+
autoComplete="off"
|
|
352
|
+
/>
|
|
353
|
+
<Button
|
|
354
|
+
type="button"
|
|
355
|
+
variant="outline"
|
|
356
|
+
size="sm"
|
|
357
|
+
disabled={saving}
|
|
358
|
+
onClick={() => handleAddUserByEmail()}
|
|
359
|
+
>
|
|
360
|
+
<UserPlus className="h-4 w-4" />
|
|
361
|
+
{t('sharing.add')}
|
|
362
|
+
</Button>
|
|
363
|
+
</div>
|
|
364
|
+
{peopleDropdownOpen && showUserDropdown && (
|
|
365
|
+
<div
|
|
366
|
+
className="absolute top-full left-0 right-0 z-10 mt-1 max-h-48 overflow-auto rounded-md border bg-popover shadow-md"
|
|
367
|
+
role="listbox"
|
|
368
|
+
>
|
|
369
|
+
{usersAvailableToAdd.map((u) => (
|
|
370
|
+
<button
|
|
371
|
+
key={u.id}
|
|
372
|
+
type="button"
|
|
373
|
+
role="option"
|
|
374
|
+
className="flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left text-sm hover:bg-accent focus:bg-accent focus:outline-none"
|
|
375
|
+
onMouseDown={(e) => {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
handleAddUser(u.id);
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<span className="font-medium">{u.name || u.email}</span>
|
|
381
|
+
{u.name && u.email && (
|
|
382
|
+
<span className="text-xs text-muted-foreground">{u.email}</span>
|
|
383
|
+
)}
|
|
384
|
+
</button>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
370
388
|
</div>
|
|
371
|
-
<p className="text-xs text-muted-foreground">
|
|
389
|
+
<p className="text-xs text-muted-foreground">
|
|
390
|
+
{t('sharing.typeToSearchUsers', { count: MIN_CHARS_FOR_USER_DROPDOWN })}
|
|
391
|
+
</p>
|
|
372
392
|
</div>
|
|
373
393
|
)}
|
|
374
394
|
|
|
375
395
|
{allowShareToTeams && (activeTab === 'teams' || !allowShareToUsers) && (
|
|
376
|
-
<
|
|
377
|
-
<
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
>
|
|
400
|
-
{team.name}
|
|
401
|
-
</CommandItem>
|
|
402
|
-
))}
|
|
403
|
-
</CommandGroup>
|
|
404
|
-
</CommandList>
|
|
405
|
-
</Command>
|
|
406
|
-
</PopoverContent>
|
|
407
|
-
</Popover>
|
|
396
|
+
<div className="space-y-2">
|
|
397
|
+
<p className="text-xs text-muted-foreground">{t('sharing.selectTeamToAdd')}</p>
|
|
398
|
+
{filteredTeams.length === 0 ? (
|
|
399
|
+
<p className="text-sm text-muted-foreground py-2">{t('common.noResults')}</p>
|
|
400
|
+
) : (
|
|
401
|
+
<ScrollArea className="max-h-48 rounded-md border">
|
|
402
|
+
<div className="p-1 space-y-0.5">
|
|
403
|
+
{filteredTeams.map((team) => (
|
|
404
|
+
<button
|
|
405
|
+
key={team.id}
|
|
406
|
+
type="button"
|
|
407
|
+
disabled={saving}
|
|
408
|
+
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-accent focus:bg-accent focus:outline-none disabled:opacity-50"
|
|
409
|
+
onClick={() => handleAddTeam(team.id)}
|
|
410
|
+
>
|
|
411
|
+
<Users className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
412
|
+
<span>{team.name}</span>
|
|
413
|
+
</button>
|
|
414
|
+
))}
|
|
415
|
+
</div>
|
|
416
|
+
</ScrollArea>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
408
419
|
)}
|
|
409
420
|
</div>
|
|
410
421
|
</div>
|
|
@@ -29,18 +29,21 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
29
29
|
|
|
30
30
|
const defaultIconClass = 'h-4 w-4';
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
33
|
+
{
|
|
34
|
+
variant = 'primary',
|
|
35
|
+
size = 'md',
|
|
36
|
+
icon: Icon,
|
|
37
|
+
iconPosition = 'left',
|
|
38
|
+
iconClassName = defaultIconClass,
|
|
39
|
+
loading = false,
|
|
40
|
+
children,
|
|
41
|
+
className = '',
|
|
42
|
+
disabled,
|
|
43
|
+
...props
|
|
44
|
+
},
|
|
45
|
+
ref
|
|
46
|
+
) {
|
|
44
47
|
const iconClass = iconClassName || defaultIconClass;
|
|
45
48
|
const content = loading ? (
|
|
46
49
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
@@ -54,6 +57,7 @@ export default function Button({
|
|
|
54
57
|
|
|
55
58
|
return (
|
|
56
59
|
<ShadcnButton
|
|
60
|
+
ref={ref}
|
|
57
61
|
className={cn(className)}
|
|
58
62
|
variant={variantMap[variant]}
|
|
59
63
|
size={sizeMap[size]}
|
|
@@ -63,6 +67,9 @@ export default function Button({
|
|
|
63
67
|
{content}
|
|
64
68
|
</ShadcnButton>
|
|
65
69
|
);
|
|
66
|
-
}
|
|
70
|
+
});
|
|
71
|
+
Button.displayName = 'Button';
|
|
72
|
+
|
|
73
|
+
export default Button;
|
|
67
74
|
|
|
68
75
|
export { buttonVariants };
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import { useAuth } from '../../contexts/AuthContext';
|
|
4
3
|
import api from '../../api/client';
|
|
5
4
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
|
6
5
|
import { Switch } from './switch';
|
|
@@ -48,7 +47,6 @@ export function SharingField({
|
|
|
48
47
|
disabled = false,
|
|
49
48
|
}: SharingFieldProps) {
|
|
50
49
|
const { t } = useTranslation();
|
|
51
|
-
const { user } = useAuth();
|
|
52
50
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
53
51
|
const [allUsers, setAllUsers] = useState<UserType[]>([]);
|
|
54
52
|
const [userSearchQuery, setUserSearchQuery] = useState('');
|
|
@@ -62,11 +60,12 @@ export function SharingField({
|
|
|
62
60
|
|
|
63
61
|
async function loadUsers() {
|
|
64
62
|
try {
|
|
65
|
-
|
|
63
|
+
// Use non-admin endpoint so any user can add people when sharing (same org/tenant).
|
|
64
|
+
const response = await api.get('/users/for-sharing');
|
|
66
65
|
const users = Array.isArray(response.data) ? response.data : [];
|
|
67
|
-
setAllUsers(users
|
|
66
|
+
setAllUsers(users);
|
|
68
67
|
} catch (error) {
|
|
69
|
-
console.error('Failed to load users:', error);
|
|
68
|
+
console.error('Failed to load users for sharing:', error);
|
|
70
69
|
}
|
|
71
70
|
}
|
|
72
71
|
|
|
@@ -387,7 +387,10 @@
|
|
|
387
387
|
"notSharedYet": "Not shared yet",
|
|
388
388
|
"emailNotAssociated": "This email is not associated with a SlugBase user yet.",
|
|
389
389
|
"removeAccess": "Remove access",
|
|
390
|
-
"add": "Add"
|
|
390
|
+
"add": "Add",
|
|
391
|
+
"noUsersOrTeams": "No users or teams could be loaded. Check the Network tab for /api/users/for-sharing and /api/teams.",
|
|
392
|
+
"typeToSearchUsers": "Type at least {{count}} characters to see matching users.",
|
|
393
|
+
"selectTeamToAdd": "Select a team to share with."
|
|
391
394
|
},
|
|
392
395
|
"plan": {
|
|
393
396
|
"limitBookmarks": "You've reached the Free plan limit ({{limit}} bookmarks). Upgrade to add more.",
|
|
@@ -180,7 +180,7 @@ export default function Bookmarks() {
|
|
|
180
180
|
|
|
181
181
|
async function loadData() {
|
|
182
182
|
try {
|
|
183
|
-
const [
|
|
183
|
+
const [bookmarksSettled, foldersSettled, tagsSettled, teamsSettled] = await Promise.allSettled([
|
|
184
184
|
api.get('/bookmarks', {
|
|
185
185
|
params: {
|
|
186
186
|
folder_id: selectedFolder || undefined,
|
|
@@ -197,13 +197,15 @@ export default function Bookmarks() {
|
|
|
197
197
|
api.get('/tags'),
|
|
198
198
|
api.get('/teams'),
|
|
199
199
|
]);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
200
|
+
if (bookmarksSettled.status === 'fulfilled') {
|
|
201
|
+
const payload = bookmarksSettled.value.data;
|
|
202
|
+
const items = payload.items ?? [];
|
|
203
|
+
setTotal(payload.total ?? 0);
|
|
204
|
+
setBookmarks(items);
|
|
205
|
+
}
|
|
206
|
+
if (foldersSettled.status === 'fulfilled') setFolders(foldersSettled.value.data ?? []);
|
|
207
|
+
if (tagsSettled.status === 'fulfilled') setTags(tagsSettled.value.data ?? []);
|
|
208
|
+
if (teamsSettled.status === 'fulfilled') setTeams(Array.isArray(teamsSettled.value.data) ? teamsSettled.value.data : []);
|
|
207
209
|
} catch (error) {
|
|
208
210
|
console.error('Failed to load data:', error);
|
|
209
211
|
} finally {
|
|
@@ -498,7 +500,7 @@ export default function Bookmarks() {
|
|
|
498
500
|
return (
|
|
499
501
|
<div className="space-y-6 pb-24">
|
|
500
502
|
{/* Sticky controls bar: header + filters/toolbar - stays visible when scrolling */}
|
|
501
|
-
<div className="sticky top-0 z-40 space-y-4 pb-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pt-0 -mt-8 bg-background
|
|
503
|
+
<div className="sticky top-0 z-40 space-y-4 pb-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pt-0 -mt-8 bg-background shadow-sm">
|
|
502
504
|
<PageHeader
|
|
503
505
|
className="pt-4"
|
|
504
506
|
title={`${t('bookmarks.title')} (${total})`}
|
|
@@ -197,7 +197,7 @@ export default function Folders() {
|
|
|
197
197
|
return (
|
|
198
198
|
<div className="space-y-6 pb-24">
|
|
199
199
|
{/* Sticky controls bar: header + toolbar - stays visible when scrolling */}
|
|
200
|
-
<div className="sticky top-0 z-40 space-y-4 pb-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pt-0 -mt-8 bg-background
|
|
200
|
+
<div className="sticky top-0 z-40 space-y-4 pb-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pt-0 -mt-8 bg-background shadow-sm">
|
|
201
201
|
<PageHeader
|
|
202
202
|
className="pt-4"
|
|
203
203
|
title={`${t('folders.title')} (${totalFolders})`}
|
|
@@ -226,7 +226,7 @@ export default function Profile() {
|
|
|
226
226
|
<span>
|
|
227
227
|
{t('profile.signedInAs')}: <span className="text-foreground">{user.email}</span>
|
|
228
228
|
</span>
|
|
229
|
-
{user.is_admin && (
|
|
229
|
+
{!!user.is_admin && (
|
|
230
230
|
<Badge variant="secondary" className="text-xs font-normal">
|
|
231
231
|
{t('profile.admin')}
|
|
232
232
|
</Badge>
|
|
@@ -172,7 +172,7 @@ export default function Tags() {
|
|
|
172
172
|
return (
|
|
173
173
|
<div className="space-y-6 pb-24">
|
|
174
174
|
{/* Sticky controls bar: header + toolbar - stays visible when scrolling */}
|
|
175
|
-
<div className="sticky top-0 z-40 space-y-4 pb-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pt-0 -mt-8 bg-background
|
|
175
|
+
<div className="sticky top-0 z-40 space-y-4 pb-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pt-0 -mt-8 bg-background shadow-sm">
|
|
176
176
|
<PageHeader
|
|
177
177
|
className="pt-4"
|
|
178
178
|
title={`${t('tags.title')} (${totalTags})`}
|