@adula/kit 0.2.0-alpha.1
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/LICENSE.md +10 -0
- package/README.md +31 -0
- package/build/agent/AGENTS.template.md +28 -0
- package/build/agent/capabilities.md +13 -0
- package/build/agent/skills/adula-frontend-design/SKILL.md +59 -0
- package/build/agent/skills/idea-review/SKILL.md +6 -0
- package/build/commands/capabilities.d.ts +6 -0
- package/build/commands/capabilities.js +9 -0
- package/build/commands/doctor.d.ts +9 -0
- package/build/commands/doctor.js +32 -0
- package/build/commands/gaps.d.ts +12 -0
- package/build/commands/gaps.js +46 -0
- package/build/commands/install.d.ts +9 -0
- package/build/commands/install.js +91 -0
- package/build/commands/main.d.ts +6 -0
- package/build/commands/main.js +27 -0
- package/build/commands/module_add.d.ts +8 -0
- package/build/commands/module_add.js +57 -0
- package/build/commands/module_remove.d.ts +10 -0
- package/build/commands/module_remove.js +94 -0
- package/build/commands/resource.d.ts +8 -0
- package/build/commands/resource.js +29 -0
- package/build/commands/storage_migrate.d.ts +12 -0
- package/build/commands/storage_migrate.js +39 -0
- package/build/commands/ui.d.ts +11 -0
- package/build/commands/ui.js +210 -0
- package/build/configure.d.ts +2 -0
- package/build/configure.js +79 -0
- package/build/database/migrations/1770000000000_kit_core.d.ts +5 -0
- package/build/database/migrations/1770000000000_kit_core.js +10 -0
- package/build/database/migrations/1770000000001_kit_attachments.d.ts +5 -0
- package/build/database/migrations/1770000000001_kit_attachments.js +10 -0
- package/build/database/migrations/1770000000002_kit_saved_views.d.ts +5 -0
- package/build/database/migrations/1770000000002_kit_saved_views.js +10 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.d.ts +5 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.js +18 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +34 -0
- package/build/providers/kit_provider.d.ts +6 -0
- package/build/providers/kit_provider.js +10 -0
- package/build/src/admin/contracts.d.ts +12 -0
- package/build/src/admin/contracts.js +93 -0
- package/build/src/admin/controller.d.ts +30 -0
- package/build/src/admin/controller.js +116 -0
- package/build/src/admin/errors.d.ts +5 -0
- package/build/src/admin/errors.js +9 -0
- package/build/src/admin/presentation.d.ts +22 -0
- package/build/src/admin/presentation.js +1 -0
- package/build/src/admin/resource_service.d.ts +170 -0
- package/build/src/admin/resource_service.js +763 -0
- package/build/src/attachments/attachment_service.d.ts +71 -0
- package/build/src/attachments/attachment_service.js +182 -0
- package/build/src/attachments/doctor.d.ts +4 -0
- package/build/src/attachments/doctor.js +28 -0
- package/build/src/attachments/storage_migrate.d.ts +39 -0
- package/build/src/attachments/storage_migrate.js +64 -0
- package/build/src/auth/ability.d.ts +22 -0
- package/build/src/auth/ability.js +31 -0
- package/build/src/auth/actor_store.d.ts +14 -0
- package/build/src/auth/actor_store.js +65 -0
- package/build/src/auth/conditions.d.ts +11 -0
- package/build/src/auth/conditions.js +81 -0
- package/build/src/auth/packed_rules.d.ts +4 -0
- package/build/src/auth/packed_rules.js +13 -0
- package/build/src/auth/sql.d.ts +11 -0
- package/build/src/auth/sql.js +114 -0
- package/build/src/commands/agent_assets.d.ts +7 -0
- package/build/src/commands/agent_assets.js +54 -0
- package/build/src/commands/doctor.d.ts +11 -0
- package/build/src/commands/doctor.js +261 -0
- package/build/src/commands/generator.d.ts +1 -0
- package/build/src/commands/generator.js +70 -0
- package/build/src/commands/source_markers.d.ts +2 -0
- package/build/src/commands/source_markers.js +9 -0
- package/build/src/core/activity.d.ts +43 -0
- package/build/src/core/activity.js +77 -0
- package/build/src/core/administration_guard.d.ts +3 -0
- package/build/src/core/administration_guard.js +37 -0
- package/build/src/core/health.d.ts +42 -0
- package/build/src/core/health.js +44 -0
- package/build/src/core/mail_test.d.ts +18 -0
- package/build/src/core/mail_test.js +88 -0
- package/build/src/core/notifications.d.ts +25 -0
- package/build/src/core/notifications.js +65 -0
- package/build/src/core/org_units.d.ts +30 -0
- package/build/src/core/org_units.js +123 -0
- package/build/src/core/roles.d.ts +72 -0
- package/build/src/core/roles.js +319 -0
- package/build/src/core/saved_views.d.ts +37 -0
- package/build/src/core/saved_views.js +139 -0
- package/build/src/core/settings.d.ts +29 -0
- package/build/src/core/settings.js +113 -0
- package/build/src/core/setup.d.ts +14 -0
- package/build/src/core/setup.js +89 -0
- package/build/src/core/ui_preferences.d.ts +12 -0
- package/build/src/core/ui_preferences.js +30 -0
- package/build/src/core/user_invitations.d.ts +23 -0
- package/build/src/core/user_invitations.js +144 -0
- package/build/src/core/users.d.ts +44 -0
- package/build/src/core/users.js +182 -0
- package/build/src/database/configure.d.ts +3 -0
- package/build/src/database/configure.js +41 -0
- package/build/src/database/schema.d.ts +7 -0
- package/build/src/database/schema.js +248 -0
- package/build/src/eslint/index.d.ts +3 -0
- package/build/src/eslint/index.js +64 -0
- package/build/src/events/adonis_jobs.d.ts +9 -0
- package/build/src/events/adonis_jobs.js +13 -0
- package/build/src/events/outbox.d.ts +18 -0
- package/build/src/events/outbox.js +33 -0
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +45 -0
- package/build/src/mcp/tools.d.ts +8 -0
- package/build/src/mcp/tools.js +156 -0
- package/build/src/org/org_service.d.ts +2 -0
- package/build/src/org/org_service.js +16 -0
- package/build/src/resource/define_resource.d.ts +4 -0
- package/build/src/resource/define_resource.js +48 -0
- package/build/src/resource/registry.d.ts +9 -0
- package/build/src/resource/registry.js +59 -0
- package/build/src/resource/types.d.ts +80 -0
- package/build/src/resource/types.js +1 -0
- package/build/src/resource/values.d.ts +5 -0
- package/build/src/resource/values.js +87 -0
- package/build/src/services/backup.d.ts +14 -0
- package/build/src/services/backup.js +59 -0
- package/build/src/services/settings.d.ts +9 -0
- package/build/src/services/settings.js +23 -0
- package/build/src/version.d.ts +1 -0
- package/build/src/version.js +2 -0
- package/build/stubs/README.md +6 -0
- package/build/stubs/controller.stub +8 -0
- package/build/stubs/main.d.ts +5 -0
- package/build/stubs/main.js +5 -0
- package/build/stubs/main.ts +5 -0
- package/build/stubs/modules.stub +6 -0
- package/build/stubs/resource_contract.stub +1 -0
- package/build/stubs/resource_contract.txt +390 -0
- package/build/stubs/routes.stub +16 -0
- package/build/stubs/service.stub +9 -0
- package/package.json +104 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Settings } from '../services/settings.js';
|
|
3
|
+
import { KitError } from '../admin/errors.js';
|
|
4
|
+
import { logActivity } from './activity.js';
|
|
5
|
+
export class InitialSetup {
|
|
6
|
+
db;
|
|
7
|
+
constructor(db) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
}
|
|
10
|
+
async check(actorId, name, fingerprint, probe) {
|
|
11
|
+
const key = `setup.check.${name}`;
|
|
12
|
+
const state = {
|
|
13
|
+
status: 'checking',
|
|
14
|
+
checkedAt: new Date().toISOString(),
|
|
15
|
+
fingerprint,
|
|
16
|
+
attempt: randomUUID(),
|
|
17
|
+
};
|
|
18
|
+
await this.db.transaction(async (trx) => {
|
|
19
|
+
await trx.raw('SELECT pg_advisory_xact_lock(?)', [717013]);
|
|
20
|
+
const previous = await new Settings(trx).get(key);
|
|
21
|
+
if (previous &&
|
|
22
|
+
Date.now() - Date.parse(previous.checkedAt) <
|
|
23
|
+
(previous.status === 'checking' ? 300_000 : 30_000))
|
|
24
|
+
throw new KitError(429, 'E_SETUP_COOLDOWN', 'انتظر قليلًا قبل إعادة الفحص');
|
|
25
|
+
await new Settings(trx).set(key, state);
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
await probe();
|
|
29
|
+
state.status = 'passed';
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
state.status = 'failed';
|
|
33
|
+
}
|
|
34
|
+
await this.db.transaction(async (trx) => {
|
|
35
|
+
await trx('settings')
|
|
36
|
+
.where({ key, scope: 'system', scope_id: '0' })
|
|
37
|
+
.whereRaw("value->>'attempt' = ?", [state.attempt])
|
|
38
|
+
.update({ value: JSON.stringify(state) });
|
|
39
|
+
await logActivity(trx, {
|
|
40
|
+
resource: 'core.setup',
|
|
41
|
+
recordId: 0,
|
|
42
|
+
actorId,
|
|
43
|
+
action: 'check',
|
|
44
|
+
changes: { service: name, status: state.status },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
if (state.status === 'failed')
|
|
48
|
+
throw new KitError(422, 'E_SETUP_CHECK_FAILED', 'لم ينجح الفحص. راجع إعدادات الخدمة واتصالها ثم أعد المحاولة.');
|
|
49
|
+
}
|
|
50
|
+
async acknowledgeIdentity(actorId, fingerprint) {
|
|
51
|
+
await this.db.transaction(async (trx) => {
|
|
52
|
+
await new Settings(trx).set('setup.identity', {
|
|
53
|
+
fingerprint,
|
|
54
|
+
actorId,
|
|
55
|
+
at: new Date().toISOString(),
|
|
56
|
+
});
|
|
57
|
+
await logActivity(trx, {
|
|
58
|
+
resource: 'core.setup',
|
|
59
|
+
recordId: 0,
|
|
60
|
+
actorId,
|
|
61
|
+
action: 'identity_confirmed',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async testNotification(actorId) {
|
|
66
|
+
return this.db.transaction(async (trx) => {
|
|
67
|
+
await trx.raw('SELECT pg_advisory_xact_lock(?, ?)', [717014, actorId]);
|
|
68
|
+
const settings = new Settings(trx);
|
|
69
|
+
const previous = await settings.get('setup.notification', 'user', String(actorId));
|
|
70
|
+
if (previous && Date.now() - Date.parse(previous.at) < 60_000)
|
|
71
|
+
throw new KitError(429, 'E_SETUP_COOLDOWN', 'انتظر دقيقة قبل إعادة الإشعار التجريبي');
|
|
72
|
+
const [notification] = await trx('notifications')
|
|
73
|
+
.insert({
|
|
74
|
+
user_id: actorId,
|
|
75
|
+
title: 'اختبار الإشعارات',
|
|
76
|
+
body: 'وصل هذا الإشعار من معالج الإعداد الأولي. حدده كمقروء ثم ارجع إلى الإعداد الأولي للتحقق من النتيجة.',
|
|
77
|
+
})
|
|
78
|
+
.returning('id');
|
|
79
|
+
await settings.set('setup.notification', { id: notification.id, at: new Date().toISOString() }, 'user', String(actorId));
|
|
80
|
+
await logActivity(trx, {
|
|
81
|
+
resource: 'core.setup',
|
|
82
|
+
recordId: notification.id,
|
|
83
|
+
actorId,
|
|
84
|
+
action: 'notification_test',
|
|
85
|
+
});
|
|
86
|
+
return notification.id;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
export type CalendarSystem = 'gregory' | 'islamic-umalqura';
|
|
3
|
+
export type CalendarPreference = CalendarSystem | 'both';
|
|
4
|
+
export type UiPreferences = {
|
|
5
|
+
calendar: CalendarPreference;
|
|
6
|
+
confirmDialogClose: boolean;
|
|
7
|
+
pageTransitions: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare const UI_PREFERENCES_KEY = "ui.preferences";
|
|
10
|
+
export declare const DEFAULT_UI_PREFERENCES: UiPreferences;
|
|
11
|
+
export declare function validateUiPreferences(value: unknown): UiPreferences;
|
|
12
|
+
export declare function uiPreferences(db: Knex): Promise<UiPreferences>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Settings } from '../services/settings.js';
|
|
2
|
+
import { KitError } from '../admin/errors.js';
|
|
3
|
+
export const UI_PREFERENCES_KEY = 'ui.preferences';
|
|
4
|
+
export const DEFAULT_UI_PREFERENCES = {
|
|
5
|
+
calendar: 'gregory',
|
|
6
|
+
confirmDialogClose: true,
|
|
7
|
+
pageTransitions: true,
|
|
8
|
+
};
|
|
9
|
+
export function validateUiPreferences(value) {
|
|
10
|
+
const input = value;
|
|
11
|
+
if (!input ||
|
|
12
|
+
typeof input !== 'object' ||
|
|
13
|
+
Array.isArray(input) ||
|
|
14
|
+
!['gregory', 'islamic-umalqura', 'both'].includes(input.calendar) ||
|
|
15
|
+
typeof input.confirmDialogClose !== 'boolean' ||
|
|
16
|
+
typeof input.pageTransitions !== 'boolean' ||
|
|
17
|
+
Object.keys(input).some((key) => !(key in DEFAULT_UI_PREFERENCES)))
|
|
18
|
+
throw new KitError(422, 'E_UI_PREFERENCES', 'اختر تقويمًا صالحًا وحدد خيارات تأكيد الإغلاق والانتقالات');
|
|
19
|
+
return {
|
|
20
|
+
calendar: input.calendar,
|
|
21
|
+
confirmDialogClose: input.confirmDialogClose,
|
|
22
|
+
pageTransitions: input.pageTransitions,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export async function uiPreferences(db) {
|
|
26
|
+
const value = await new Settings(db).get(UI_PREFERENCES_KEY);
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
return { ...DEFAULT_UI_PREFERENCES };
|
|
29
|
+
return validateUiPreferences(value);
|
|
30
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
export type InvitationDelivery = {
|
|
3
|
+
email: string;
|
|
4
|
+
fullName: string;
|
|
5
|
+
token: string;
|
|
6
|
+
};
|
|
7
|
+
/** Pending invitations are not accounts. Password hashing and mail transport belong to the host. */
|
|
8
|
+
export declare class UserInvitations {
|
|
9
|
+
private db;
|
|
10
|
+
constructor(db: Knex);
|
|
11
|
+
canInvite(actorId: number): Promise<boolean>;
|
|
12
|
+
invite(actorId: number, input: {
|
|
13
|
+
email: string;
|
|
14
|
+
fullName: string;
|
|
15
|
+
}, send: (invitation: InvitationDelivery) => Promise<void>): Promise<{
|
|
16
|
+
id: number;
|
|
17
|
+
email: string;
|
|
18
|
+
}>;
|
|
19
|
+
valid(token: string): Promise<boolean>;
|
|
20
|
+
accept(token: string, password: string, hashPassword: (password: string) => Promise<string>): Promise<{
|
|
21
|
+
id: number;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { subject } from '@casl/ability';
|
|
3
|
+
import { ActorStore } from '../auth/actor_store.js';
|
|
4
|
+
import { buildAbility } from '../auth/ability.js';
|
|
5
|
+
import { ResourceRegistry } from '../resource/registry.js';
|
|
6
|
+
import { KitError } from '../admin/errors.js';
|
|
7
|
+
import { logActivity } from './activity.js';
|
|
8
|
+
const RESOURCE = 'core.users';
|
|
9
|
+
const INVITATIONS = 'core.user_invitations';
|
|
10
|
+
const digest = (token) => createHash('sha256').update(token).digest('hex');
|
|
11
|
+
const invalid = () => new KitError(422, 'E_INVITATION_INVALID', 'الدعوة غير صالحة أو انتهت صلاحيتها. اطلب دعوة جديدة من المسؤول.');
|
|
12
|
+
/** Pending invitations are not accounts. Password hashing and mail transport belong to the host. */
|
|
13
|
+
export class UserInvitations {
|
|
14
|
+
db;
|
|
15
|
+
constructor(db) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
}
|
|
18
|
+
async canInvite(actorId) {
|
|
19
|
+
const user = await this.db('users').where('id', actorId).first('disabled_at');
|
|
20
|
+
if (!user || user.disabled_at)
|
|
21
|
+
return false;
|
|
22
|
+
const actor = await new ActorStore(this.db, new ResourceRegistry()).load(actorId);
|
|
23
|
+
return buildAbility(actor.rules).can('invite', subject(RESOURCE, {}));
|
|
24
|
+
}
|
|
25
|
+
async invite(actorId, input, send) {
|
|
26
|
+
const email = typeof input.email === 'string' ? input.email.trim().toLowerCase() : '';
|
|
27
|
+
const fullName = typeof input.fullName === 'string' ? input.fullName.trim() : '';
|
|
28
|
+
if (!email ||
|
|
29
|
+
email.length > 254 ||
|
|
30
|
+
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ||
|
|
31
|
+
!fullName ||
|
|
32
|
+
fullName.length > 120)
|
|
33
|
+
throw new KitError(422, 'E_INVITATION_INPUT', 'أدخل اسمًا وبريدًا إلكترونيًا صحيحين');
|
|
34
|
+
const token = randomBytes(32).toString('base64url');
|
|
35
|
+
const tokenHash = digest(token);
|
|
36
|
+
const id = await this.db.transaction(async (trx) => {
|
|
37
|
+
// Serialize resends and acceptance for the same normalized email.
|
|
38
|
+
await trx.raw('SELECT pg_advisory_xact_lock(hashtextextended(?, 0))', [email]);
|
|
39
|
+
if (!(await new UserInvitations(trx).canInvite(actorId)))
|
|
40
|
+
throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية دعوة المستخدمين');
|
|
41
|
+
if (await trx('users').whereRaw('lower(email) = ?', [email]).first('id'))
|
|
42
|
+
throw new KitError(409, 'E_USER_EXISTS', 'يوجد حساب بهذا البريد بالفعل');
|
|
43
|
+
const previous = await trx('user_invitations').where({ email }).first();
|
|
44
|
+
if (previous && Date.now() - new Date(previous.created_at).getTime() < 60_000)
|
|
45
|
+
throw new KitError(429, 'E_INVITATION_WAIT', 'انتظر دقيقة قبل إعادة إرسال الدعوة');
|
|
46
|
+
const [row] = await trx('user_invitations')
|
|
47
|
+
.insert({
|
|
48
|
+
email,
|
|
49
|
+
full_name: fullName,
|
|
50
|
+
invited_by: actorId,
|
|
51
|
+
token_hash: tokenHash,
|
|
52
|
+
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
53
|
+
accepted_at: null,
|
|
54
|
+
created_at: trx.fn.now(),
|
|
55
|
+
})
|
|
56
|
+
.onConflict('email')
|
|
57
|
+
.merge()
|
|
58
|
+
.returning('id');
|
|
59
|
+
await logActivity(trx, {
|
|
60
|
+
resource: INVITATIONS,
|
|
61
|
+
recordId: row.id,
|
|
62
|
+
actorId,
|
|
63
|
+
action: 'invitation_created',
|
|
64
|
+
changes: { email },
|
|
65
|
+
});
|
|
66
|
+
return row.id;
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
await send({ email, fullName, token });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Revoke only this attempt; a newer resend must survive an older transport failure.
|
|
73
|
+
await this.db('user_invitations')
|
|
74
|
+
.where({ id, token_hash: tokenHash })
|
|
75
|
+
.whereNull('accepted_at')
|
|
76
|
+
.delete();
|
|
77
|
+
await logActivity(this.db, {
|
|
78
|
+
resource: INVITATIONS,
|
|
79
|
+
recordId: id,
|
|
80
|
+
actorId,
|
|
81
|
+
action: 'invitation_delivery_failed',
|
|
82
|
+
changes: { email },
|
|
83
|
+
});
|
|
84
|
+
throw new KitError(503, 'E_INVITATION_DELIVERY', 'تعذر إرسال الدعوة. تحقق من إعدادات البريد ثم أعد المحاولة.');
|
|
85
|
+
}
|
|
86
|
+
await logActivity(this.db, {
|
|
87
|
+
resource: INVITATIONS,
|
|
88
|
+
recordId: id,
|
|
89
|
+
actorId,
|
|
90
|
+
action: 'invitation_sent',
|
|
91
|
+
changes: { email },
|
|
92
|
+
});
|
|
93
|
+
return { id, email };
|
|
94
|
+
}
|
|
95
|
+
async valid(token) {
|
|
96
|
+
if (!/^[\w-]{43}$/.test(token))
|
|
97
|
+
return false;
|
|
98
|
+
return Boolean(await this.db('user_invitations')
|
|
99
|
+
.where('token_hash', digest(token))
|
|
100
|
+
.whereNull('accepted_at')
|
|
101
|
+
.where('expires_at', '>', this.db.fn.now())
|
|
102
|
+
.first('id'));
|
|
103
|
+
}
|
|
104
|
+
async accept(token, password, hashPassword) {
|
|
105
|
+
if (!(await this.valid(token)))
|
|
106
|
+
throw invalid();
|
|
107
|
+
if (typeof password !== 'string' || password.length < 8 || password.length > 32)
|
|
108
|
+
throw new KitError(422, 'E_PASSWORD', 'كلمة المرور من 8 إلى 32 حرفًا');
|
|
109
|
+
const passwordHash = await hashPassword(password);
|
|
110
|
+
return this.db.transaction(async (trx) => {
|
|
111
|
+
const pending = await trx('user_invitations').where('token_hash', digest(token)).first();
|
|
112
|
+
if (!pending)
|
|
113
|
+
throw invalid();
|
|
114
|
+
await trx.raw('SELECT pg_advisory_xact_lock(hashtextextended(?, 0))', [pending.email]);
|
|
115
|
+
const row = await trx('user_invitations')
|
|
116
|
+
.where({ id: pending.id, token_hash: digest(token) })
|
|
117
|
+
.whereNull('accepted_at')
|
|
118
|
+
.where('expires_at', '>', trx.fn.now())
|
|
119
|
+
.forUpdate()
|
|
120
|
+
.first();
|
|
121
|
+
if (!row || (await trx('users').whereRaw('lower(email) = ?', [row.email]).first('id')))
|
|
122
|
+
throw invalid();
|
|
123
|
+
const [user] = await trx('users')
|
|
124
|
+
.insert({
|
|
125
|
+
email: row.email,
|
|
126
|
+
full_name: row.full_name,
|
|
127
|
+
password: passwordHash,
|
|
128
|
+
created_at: trx.fn.now(),
|
|
129
|
+
updated_at: trx.fn.now(),
|
|
130
|
+
})
|
|
131
|
+
.returning('id');
|
|
132
|
+
await trx('user_invitations').where('id', row.id).update({ accepted_at: trx.fn.now() });
|
|
133
|
+
// No role or organizational membership can be supplied by either HTTP request.
|
|
134
|
+
await logActivity(trx, {
|
|
135
|
+
resource: RESOURCE,
|
|
136
|
+
recordId: user.id,
|
|
137
|
+
actorId: user.id,
|
|
138
|
+
action: 'invitation_accepted',
|
|
139
|
+
changes: { invitedBy: row.invited_by },
|
|
140
|
+
});
|
|
141
|
+
return { id: user.id };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
export type UserRoleAssignment = {
|
|
3
|
+
id: number;
|
|
4
|
+
roleId: number;
|
|
5
|
+
role: string;
|
|
6
|
+
orgUnitId: number | null;
|
|
7
|
+
orgUnit: string | null;
|
|
8
|
+
};
|
|
9
|
+
export type UserOrgUnit = {
|
|
10
|
+
id: number;
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
};
|
|
14
|
+
export type UserSummary = {
|
|
15
|
+
id: number;
|
|
16
|
+
fullName: string | null;
|
|
17
|
+
email: string;
|
|
18
|
+
disabledAt: string | null;
|
|
19
|
+
roles: UserRoleAssignment[];
|
|
20
|
+
orgUnits: UserOrgUnit[];
|
|
21
|
+
};
|
|
22
|
+
export type UserPage = {
|
|
23
|
+
data: UserSummary[];
|
|
24
|
+
nextCursor: number | null;
|
|
25
|
+
};
|
|
26
|
+
/** Host applications own the users table; the kit expects id, email, full_name and disabled_at. */
|
|
27
|
+
export declare class UsersAdmin {
|
|
28
|
+
private db;
|
|
29
|
+
constructor(db: Knex);
|
|
30
|
+
list(options?: {
|
|
31
|
+
search?: string;
|
|
32
|
+
cursor?: number;
|
|
33
|
+
limit?: number;
|
|
34
|
+
}): Promise<UserPage>;
|
|
35
|
+
get(id: number): Promise<UserSummary>;
|
|
36
|
+
assignRole(actorId: number, userId: number, roleId: number, orgUnitId?: number | null): Promise<number>;
|
|
37
|
+
removeRole(actorId: number, userId: number, assignmentId: number): Promise<void>;
|
|
38
|
+
assignOrgUnit(actorId: number, userId: number, orgUnitId: number): Promise<void>;
|
|
39
|
+
removeOrgUnit(actorId: number, userId: number, orgUnitId: number): Promise<void>;
|
|
40
|
+
disable(actorId: number, userId: number): Promise<void>;
|
|
41
|
+
enable(actorId: number, userId: number): Promise<void>;
|
|
42
|
+
private find;
|
|
43
|
+
private hydrate;
|
|
44
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { KitError } from '../admin/errors.js';
|
|
2
|
+
import { logActivity, pageLimit } from './activity.js';
|
|
3
|
+
import { administrationGuard } from './administration_guard.js';
|
|
4
|
+
const RESOURCE = 'core.users';
|
|
5
|
+
const escapeLike = (value) => value.replace(/[\\%_]/g, '\\$&');
|
|
6
|
+
/** Host applications own the users table; the kit expects id, email, full_name and disabled_at. */
|
|
7
|
+
export class UsersAdmin {
|
|
8
|
+
db;
|
|
9
|
+
constructor(db) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
}
|
|
12
|
+
async list(options = {}) {
|
|
13
|
+
const limit = pageLimit(options.limit);
|
|
14
|
+
const query = this.db('users')
|
|
15
|
+
.select('id', 'email', 'full_name', 'disabled_at')
|
|
16
|
+
.orderBy('id')
|
|
17
|
+
.limit(limit + 1);
|
|
18
|
+
const search = options.search?.trim();
|
|
19
|
+
if (search) {
|
|
20
|
+
const pattern = `%${escapeLike(search)}%`;
|
|
21
|
+
query.where((where) => where.whereILike('email', pattern).orWhereILike('full_name', pattern));
|
|
22
|
+
}
|
|
23
|
+
if (options.cursor)
|
|
24
|
+
query.where('id', '>', options.cursor);
|
|
25
|
+
const rows = await query;
|
|
26
|
+
const page = rows.slice(0, limit);
|
|
27
|
+
const data = await this.hydrate(page);
|
|
28
|
+
return { data, nextCursor: rows.length > limit ? page[page.length - 1].id : null };
|
|
29
|
+
}
|
|
30
|
+
async get(id) {
|
|
31
|
+
const [user] = await this.hydrate([await this.find(this.db, id)]);
|
|
32
|
+
return user;
|
|
33
|
+
}
|
|
34
|
+
async assignRole(actorId, userId, roleId, orgUnitId = null) {
|
|
35
|
+
return this.db.transaction(async (trx) => {
|
|
36
|
+
const verifyAccess = await administrationGuard(trx, actorId);
|
|
37
|
+
await this.find(trx, userId);
|
|
38
|
+
const role = await trx('roles').where('id', roleId).first();
|
|
39
|
+
if (!role)
|
|
40
|
+
throw new KitError(404, 'E_ROLE_NOT_FOUND', 'الدور غير موجود');
|
|
41
|
+
if (orgUnitId !== null && !(await trx('org_units').where('id', orgUnitId).first()))
|
|
42
|
+
throw new KitError(404, 'E_ORG_NOT_FOUND', 'الوحدة التنظيمية غير موجودة');
|
|
43
|
+
const duplicate = await trx('user_roles')
|
|
44
|
+
.where({ user_id: userId, role_id: roleId })
|
|
45
|
+
.andWhere((where) => orgUnitId === null
|
|
46
|
+
? where.whereNull('org_unit_id')
|
|
47
|
+
: where.where('org_unit_id', orgUnitId))
|
|
48
|
+
.first();
|
|
49
|
+
if (duplicate)
|
|
50
|
+
throw new KitError(409, 'E_ROLE_ASSIGNED', 'هذا الدور مسند بالفعل');
|
|
51
|
+
const [row] = await trx('user_roles')
|
|
52
|
+
.insert({ user_id: userId, role_id: roleId, org_unit_id: orgUnitId })
|
|
53
|
+
.returning('id');
|
|
54
|
+
await verifyAccess();
|
|
55
|
+
await logActivity(trx, {
|
|
56
|
+
resource: RESOURCE,
|
|
57
|
+
recordId: userId,
|
|
58
|
+
actorId,
|
|
59
|
+
action: 'assign_role',
|
|
60
|
+
changes: { roleId, role: role.name, orgUnitId },
|
|
61
|
+
});
|
|
62
|
+
return row.id;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async removeRole(actorId, userId, assignmentId) {
|
|
66
|
+
return this.db.transaction(async (trx) => {
|
|
67
|
+
const verifyAccess = await administrationGuard(trx, actorId);
|
|
68
|
+
const [row] = await trx('user_roles')
|
|
69
|
+
.where({ id: assignmentId, user_id: userId })
|
|
70
|
+
.delete()
|
|
71
|
+
.returning('*');
|
|
72
|
+
if (!row)
|
|
73
|
+
throw new KitError(404, 'E_ROLE_NOT_ASSIGNED', 'الإسناد غير موجود');
|
|
74
|
+
await verifyAccess();
|
|
75
|
+
await logActivity(trx, {
|
|
76
|
+
resource: RESOURCE,
|
|
77
|
+
recordId: userId,
|
|
78
|
+
actorId,
|
|
79
|
+
action: 'remove_role',
|
|
80
|
+
changes: { roleId: row.role_id, orgUnitId: row.org_unit_id },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async assignOrgUnit(actorId, userId, orgUnitId) {
|
|
85
|
+
return this.db.transaction(async (trx) => {
|
|
86
|
+
await this.find(trx, userId);
|
|
87
|
+
const unit = await trx('org_units').where('id', orgUnitId).first();
|
|
88
|
+
if (!unit)
|
|
89
|
+
throw new KitError(404, 'E_ORG_NOT_FOUND', 'الوحدة التنظيمية غير موجودة');
|
|
90
|
+
if (await trx('user_org_units').where({ user_id: userId, org_unit_id: orgUnitId }).first())
|
|
91
|
+
throw new KitError(409, 'E_MEMBERSHIP_EXISTS', 'المستخدم عضو في هذه الوحدة بالفعل');
|
|
92
|
+
await trx('user_org_units').insert({ user_id: userId, org_unit_id: orgUnitId });
|
|
93
|
+
await logActivity(trx, {
|
|
94
|
+
resource: RESOURCE,
|
|
95
|
+
recordId: userId,
|
|
96
|
+
actorId,
|
|
97
|
+
action: 'assign_org_unit',
|
|
98
|
+
changes: { orgUnitId, orgUnit: unit.name },
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async removeOrgUnit(actorId, userId, orgUnitId) {
|
|
103
|
+
return this.db.transaction(async (trx) => {
|
|
104
|
+
const removed = await trx('user_org_units')
|
|
105
|
+
.where({ user_id: userId, org_unit_id: orgUnitId })
|
|
106
|
+
.delete();
|
|
107
|
+
if (!removed)
|
|
108
|
+
throw new KitError(404, 'E_MEMBERSHIP_NOT_FOUND', 'العضوية غير موجودة');
|
|
109
|
+
await logActivity(trx, {
|
|
110
|
+
resource: RESOURCE,
|
|
111
|
+
recordId: userId,
|
|
112
|
+
actorId,
|
|
113
|
+
action: 'remove_org_unit',
|
|
114
|
+
changes: { orgUnitId },
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async disable(actorId, userId) {
|
|
119
|
+
if (actorId === userId)
|
|
120
|
+
throw new KitError(422, 'E_SELF_DISABLE', 'لا يمكنك تعطيل حسابك الحالي');
|
|
121
|
+
return this.db.transaction(async (trx) => {
|
|
122
|
+
const verifyAccess = await administrationGuard(trx, actorId);
|
|
123
|
+
await this.find(trx, userId);
|
|
124
|
+
await trx('users')
|
|
125
|
+
.where('id', userId)
|
|
126
|
+
.whereNull('disabled_at')
|
|
127
|
+
.update({ disabled_at: trx.fn.now() });
|
|
128
|
+
await verifyAccess();
|
|
129
|
+
await logActivity(trx, { resource: RESOURCE, recordId: userId, actorId, action: 'disable' });
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async enable(actorId, userId) {
|
|
133
|
+
return this.db.transaction(async (trx) => {
|
|
134
|
+
await this.find(trx, userId);
|
|
135
|
+
await trx('users').where('id', userId).update({ disabled_at: null });
|
|
136
|
+
await logActivity(trx, { resource: RESOURCE, recordId: userId, actorId, action: 'enable' });
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async find(db, id) {
|
|
140
|
+
const user = await db('users')
|
|
141
|
+
.select('id', 'email', 'full_name', 'disabled_at')
|
|
142
|
+
.where('id', id)
|
|
143
|
+
.first();
|
|
144
|
+
if (!user)
|
|
145
|
+
throw new KitError(404, 'E_USER_NOT_FOUND', 'المستخدم غير موجود');
|
|
146
|
+
return user;
|
|
147
|
+
}
|
|
148
|
+
async hydrate(rows) {
|
|
149
|
+
if (!rows.length)
|
|
150
|
+
return [];
|
|
151
|
+
const ids = rows.map((row) => row.id);
|
|
152
|
+
const roles = await this.db('user_roles as ur')
|
|
153
|
+
.join('roles as r', 'r.id', 'ur.role_id')
|
|
154
|
+
.leftJoin('org_units as o', 'o.id', 'ur.org_unit_id')
|
|
155
|
+
.whereIn('ur.user_id', ids)
|
|
156
|
+
.select('ur.id', 'ur.user_id', 'ur.role_id', 'r.name as role', 'ur.org_unit_id', 'o.name as org_unit')
|
|
157
|
+
.orderBy(['r.name', 'ur.id']);
|
|
158
|
+
const units = await this.db('user_org_units as m')
|
|
159
|
+
.join('org_units as o', 'o.id', 'm.org_unit_id')
|
|
160
|
+
.whereIn('m.user_id', ids)
|
|
161
|
+
.select('m.user_id', 'o.id', 'o.name', 'o.path')
|
|
162
|
+
.orderBy('o.path');
|
|
163
|
+
return rows.map((row) => ({
|
|
164
|
+
id: row.id,
|
|
165
|
+
fullName: row.full_name ?? null,
|
|
166
|
+
email: row.email,
|
|
167
|
+
disabledAt: row.disabled_at ? new Date(row.disabled_at).toISOString() : null,
|
|
168
|
+
roles: roles
|
|
169
|
+
.filter((role) => role.user_id === row.id)
|
|
170
|
+
.map((role) => ({
|
|
171
|
+
id: role.id,
|
|
172
|
+
roleId: role.role_id,
|
|
173
|
+
role: role.role,
|
|
174
|
+
orgUnitId: role.org_unit_id ?? null,
|
|
175
|
+
orgUnit: role.org_unit ?? null,
|
|
176
|
+
})),
|
|
177
|
+
orgUnits: units
|
|
178
|
+
.filter((unit) => unit.user_id === row.id)
|
|
179
|
+
.map((unit) => ({ id: unit.id, name: unit.name, path: String(unit.path) })),
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** Migration names stay relative across source, packed and production installs. */
|
|
4
|
+
export function withKitDatabase(config, root) {
|
|
5
|
+
const name = config.connection;
|
|
6
|
+
const connection = name ? config.connections[name] : undefined;
|
|
7
|
+
if (!connection || connection.client !== 'pg')
|
|
8
|
+
throw new Error('adula-kit requires a configured PostgreSQL connection');
|
|
9
|
+
let modules = [];
|
|
10
|
+
try {
|
|
11
|
+
modules = readdirSync(join(root, 'app/modules'), { withFileTypes: true })
|
|
12
|
+
.filter((entry) => entry.isDirectory() && /^[a-z][a-z0-9_]*$/.test(entry.name))
|
|
13
|
+
.map((entry) => `app/modules/${entry.name}/migrations`)
|
|
14
|
+
.sort();
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code !== 'ENOENT')
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
const migrations = connection.migrations ?? {};
|
|
21
|
+
return {
|
|
22
|
+
...config,
|
|
23
|
+
connections: {
|
|
24
|
+
...config.connections,
|
|
25
|
+
[name]: {
|
|
26
|
+
...connection,
|
|
27
|
+
migrations: {
|
|
28
|
+
...migrations,
|
|
29
|
+
naturalSort: true,
|
|
30
|
+
paths: [
|
|
31
|
+
...new Set([
|
|
32
|
+
...(migrations.paths ?? ['database/migrations']),
|
|
33
|
+
'node_modules/@adula/kit/build/database/migrations',
|
|
34
|
+
...modules,
|
|
35
|
+
]),
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { Resource } from '../resource/types.js';
|
|
3
|
+
export declare function createCoreSchema(db: Knex): Promise<void>;
|
|
4
|
+
/** Attachment metadata lives in one table so ownership, restore drills and disk moves see every file. */
|
|
5
|
+
export declare function createAttachmentsSchema(db: Knex): Promise<void>;
|
|
6
|
+
export declare function createSavedViewsSchema(db: Knex): Promise<void>;
|
|
7
|
+
export declare function createResourceTable(db: Knex, resource: Pick<Resource, 'name' | 'scoped' | 'version' | 'submittable' | 'customFields' | 'fields'>): Promise<void>;
|