@adula/create-app 0.2.0-alpha.1 → 0.2.0-alpha.2
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/README.md +25 -2
- package/build/cli.mjs +48 -6
- package/build/prerequisites.mjs +71 -0
- package/build/project.mjs +1 -1
- package/build/system.mjs +59 -0
- package/build/template.json +1 -1
- package/package.json +1 -1
package/build/template.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.2.0-alpha.1","files":{"docs/initial-setup.md":"# Initial application setup\n\nAfter creation, sign in as administrator and open **الإعداد الأولي** (`/admin/setup`). Only users with `manage all` can access the page and its actions. Resume later without losing recorded results.\n\n1. Review and explicitly approve the installed company identity. Complete project-owned `docs/design-identity.md`, `company-identity.json`, `inertia/brand.ts` and brand assets if provisional. Checklist approval does not rewrite branding. Calendar/dialog preferences remain under settings.\n2. Send an internal test notification, open the inbox, mark it read and return. Evidence belongs to the signed-in administrator.\n3. Configure SMTP, restart web/worker processes, then select **إرسال بريد تجريبي**. The recipient is the administrator's stored email, never a request-supplied address. Compare the attempt reference in the email before selecting **وصلت الرسالة**; otherwise select **لم تصل الرسالة**. Reopen the dialog later if needed. Pending confirmations expire after 24 hours. Configuration changes invalidate old evidence. SMTP acceptance alone stays pending.\n4. Run the storage check: write a unique private test file, read/compare its content, then delete that file. Business attachments are untouched. Preserve local volumes or configure S3. Changing disks does not move existing files; use the documented storage migration flow.\n5. Check PostgreSQL/Redis connectivity. Run `node ace adula:worker` continuously and exactly one `node ace scheduler:run` under the deployment process manager. Review fresh heartbeats and failed jobs in **تشغيل النظام**. Connectivity does not prove job execution.\n6. Configure offsite backup storage and the supplied backup service. Run `node ace backup:verify` to inspect snapshot objects. Download an offsite snapshot and use `backup:restore-test` to verify a restored record and attachment in an isolated temporary database. Local snapshots do not establish offsite acceptance.\n7. Google/GitHub sign-in is optional and does not block the base release ([ADR 021](decisions/021-optional-oauth.md)); the owner declined it for the current setup. Leave credentials empty unless requested. When enabled, configure the provider and callback URLs. Try real sign-in in another session while retaining administrator access. Configuration alone never counts as verified login.\n\n## Environment configuration\n\n| Service | Variables |\n| ------- | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| Mail | `MAIL_FROM_NAME`, `MAIL_FROM_ADDRESS`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` |\n| TLS | `SMTP_SECURE` for implicit TLS; `SMTP_REQUIRE_TLS` for STARTTLS. Without overrides, port 465 uses implicit TLS and production requires TLS. |\n| File S3 | `DRIVE_DISK=s3`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET`, optional `AWS_ENDPOINT` |\n| Backups | `BACKUP_S3_ENDPOINT`, `BACKUP_S3_BUCKET`, `BACKUP_S3_REGION`, `BACKUP_S3_ACCESS_KEY_ID`, `BACKUP_S3_SECRET_ACCESS_KEY` |\n| OAuth | `APP_URL`, plus `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` |\n\nVerify the sender/domain with the mail provider. Keep secrets out of Git and generic JSON settings. Restart affected processes after environment changes and repeat the relevant test. Never disable certificate verification to make a test pass.\n\nS3 uses `supportsACL: false` with private visibility so uploads work with bucket-owner-enforced buckets and S3-compatible providers that disable ACLs. Keep public access blocked and scope IAM access to the intended bucket. This option belongs in `config/drive.ts`, not `.env`; it does not grant bucket permissions. After changing the configuration, restart the application and run the storage roundtrip check. A successful local-disk check does not validate S3.\n\n## Adding users\n\nFresh projects include **إضافة مستخدم** in user administration and **دعوة مستخدم** in navigation. Administrators may delegate only **دعوات المستخدمين → دعوة مستخدم** through the roles matrix and a deployment-wide role assignment. This permission does not expose the administrative user list or grant role-assignment authority.\n\nConfigure SMTP and `APP_URL` before sending invitations. Enter the user's name and email; the message contains a single-use link valid for 24 hours. The account is created only when the recipient chooses a password. The administrator then assigns business roles and organizational membership. To resend an unaccepted invitation, enter the same email after one minute; the earlier link is invalidated. Delivery failure is shown inside the dialog and can be retried. Existing accounts are managed from the users screen, never replaced by an invitation.\n\n## Evidence limits\n\nProtected `setup.*` settings hold setup evidence; `mail.delivery_test` holds per-administrator mail attempts. PostgreSQL tests cover races, stale confirmations, failures and evidence tampering. The loopback SMTP sink exercises a real SMTP conversation but proves no external inbox delivery. Production acceptance requires real infrastructure and administrator receipt confirmation.\n","app/controllers/account_sessions_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { Exception } from '@adonisjs/core/exceptions'\nimport { listUserSessions, revokeSession, revokeUserSessions } from '#services/sessions'\n\nexport default class AccountSessionsController {\n async index({ inertia, auth, session }: HttpContext) {\n const user = auth.getUserOrFail()\n return inertia.render('account/sessions', {\n sessions: await listUserSessions(user.id),\n currentSessionId: session.sessionId,\n })\n }\n\n /** Ending the current session signs the user out at once. */\n async destroy({ auth, params, response, session }: HttpContext) {\n const user = auth.getUserOrFail()\n const id = String(params.id)\n const sessions = await listUserSessions(user.id)\n if (!sessions.some((entry) => entry.id === id))\n throw new Exception('الجلسة غير موجودة', {\n status: 404,\n code: 'E_ROUTE_NOT_FOUND',\n })\n await revokeSession(id, user.id)\n if (id === session.sessionId) {\n await auth.use('web').logout()\n session.flash('success', 'أُنهيت جلستك الحالية. سجّل الدخول مجدداً عند الحاجة.')\n return response.redirect().toRoute('session.create')\n }\n session.flash('success', 'أُنهيت الجلسة.')\n return response.redirect().toRoute('account_sessions.index')\n }\n\n async purge({ auth, response, session }: HttpContext) {\n const user = auth.getUserOrFail()\n const count = await revokeUserSessions(user.id, user.id, { except: session.sessionId })\n session.flash('success', count ? `أُنهيت ${count} من الجلسات الأخرى.` : 'لا توجد جلسات أخرى.')\n return response.redirect().toRoute('account_sessions.index')\n }\n}\n","app/controllers/admin/activity_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { ActivityAdmin } from '@adula/kit'\nimport { knex, optionalId, text, wantsJson } from './support.js'\n\nexport default class ActivityController {\n async index(ctx: HttpContext) {\n const filters = {\n resource: text(ctx.request.input('resource')),\n action: text(ctx.request.input('action')),\n actorId: optionalId(ctx.request.input('actorId')) ?? undefined,\n from: text(ctx.request.input('from')),\n to: text(ctx.request.input('to')),\n }\n const service = new ActivityAdmin(knex())\n const activity = await service.list({\n ...filters,\n cursor: text(ctx.request.input('cursor')),\n limit: ctx.request.input('limit'),\n })\n if (wantsJson(ctx)) return activity\n return ctx.inertia.render('admin/activity/index', {\n activity,\n facets: await service.facets(),\n filters: {\n resource: filters.resource ?? '',\n action: filters.action ?? '',\n actorId: filters.actorId ? String(filters.actorId) : '',\n from: filters.from ?? '',\n to: filters.to ?? '',\n },\n })\n }\n}\n","app/controllers/admin/jobs_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport queue from '@nemoventures/adonis-jobs/services/main'\nimport { KitError, logActivity, runtimeHealth, type QueueSnapshot } from '@adula/kit'\nimport { actorId, knex, mutate, wantsJson } from './support.js'\n\n/** BullMQ stays in the application; the kit only defines the snapshot shape. */\nasync function snapshot(): Promise<QueueSnapshot> {\n const events = queue.useQueue('events')\n const counts = await events.getJobCounts('waiting', 'active', 'delayed', 'failed', 'completed')\n const failed = await events.getFailed(0, 49)\n return {\n name: 'events',\n counts: {\n waiting: counts.waiting ?? 0,\n active: counts.active ?? 0,\n delayed: counts.delayed ?? 0,\n failed: counts.failed ?? 0,\n completed: counts.completed ?? 0,\n },\n failed: failed.map((job) => ({\n id: String(job.id),\n name: job.name,\n attemptsMade: job.attemptsMade,\n failedReason: job.failedReason ?? '',\n failedAt: job.finishedOn ? new Date(job.finishedOn).toISOString() : null,\n })),\n }\n}\n\nexport default class JobsController {\n async index(ctx: HttpContext) {\n const props = { health: await runtimeHealth(knex()), queues: [await snapshot()] }\n if (wantsJson(ctx)) return props\n return ctx.inertia.render('admin/jobs/index', props)\n }\n\n async retry(ctx: HttpContext) {\n const id = String(ctx.params.id)\n if (!/^[\\w-]{1,128}$/.test(id)) throw new KitError(404, 'E_JOB_NOT_FOUND', 'الوظيفة غير موجودة')\n return mutate(\n ctx,\n async () => {\n const job = await queue.useQueue('events').getJob(id)\n if (!job) throw new KitError(404, 'E_JOB_NOT_FOUND', 'الوظيفة غير موجودة')\n if (!(await job.isFailed()))\n throw new KitError(422, 'E_JOB_NOT_FAILED', 'يمكن إعادة المحاولة للوظائف الفاشلة فقط')\n await job.retry()\n await logActivity(knex(), {\n resource: 'core.jobs',\n recordId: 0,\n actorId: actorId(ctx),\n action: 'retry',\n changes: { jobId: id, name: job.name },\n })\n return { id, state: await job.getState() }\n },\n 'أعيدت الوظيفة إلى الطابور'\n )\n }\n}\n","app/controllers/admin/notifications_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { NotificationsAdmin } from '@adula/kit'\nimport { actorId, knex, mutate, positiveId, text, wantsJson } from './support.js'\n\n/** Every signed-in user reads their own inbox; there is no administrative view of it. */\nexport default class NotificationsController {\n async index(ctx: HttpContext) {\n const notifications = await new NotificationsAdmin(knex()).list(actorId(ctx), {\n cursor: text(ctx.request.input('cursor')),\n limit: ctx.request.input('limit'),\n })\n if (wantsJson(ctx)) return notifications\n return ctx.inertia.render('admin/notifications/index', { notifications })\n }\n\n async read(ctx: HttpContext) {\n return mutate(\n ctx,\n () => new NotificationsAdmin(knex()).markRead(actorId(ctx), positiveId(ctx.params.id)),\n 'تم تعيين الإشعار كمقروء'\n )\n }\n\n async readAll(ctx: HttpContext) {\n return mutate(\n ctx,\n () => new NotificationsAdmin(knex()).markAllRead(actorId(ctx)),\n 'تم تعيين كل الإشعارات كمقروءة'\n )\n }\n}\n","app/controllers/admin/org_units_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { OrgUnitsAdmin } from '@adula/kit'\nimport { kit } from '#services/kit'\nimport { actorId, knex, mutate, optionalId, positiveId, wantsJson } from './support.js'\n\nconst service = () => new OrgUnitsAdmin(knex(), kit().registry)\n\nexport default class OrgUnitsController {\n async index(ctx: HttpContext) {\n const units = await service().tree()\n if (wantsJson(ctx)) return { data: units }\n return ctx.inertia.render('admin/org_units/index', { units })\n }\n\n async store(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().create(actorId(ctx), {\n parentId: optionalId(ctx.request.input('parentId')),\n name: ctx.request.input('name'),\n type: ctx.request.input('type'),\n }),\n 'تمت إضافة الوحدة'\n )\n }\n\n async update(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().rename(actorId(ctx), positiveId(ctx.params.id), ctx.request.input('name')),\n 'تمت إعادة التسمية'\n )\n }\n\n async move(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().move(\n actorId(ctx),\n positiveId(ctx.params.id),\n optionalId(ctx.request.input('parentId'))\n ),\n 'تم نقل الوحدة'\n )\n }\n\n async destroy(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().delete(actorId(ctx), positiveId(ctx.params.id)),\n 'تم حذف الوحدة'\n )\n }\n}\n","app/controllers/admin/roles_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { RolesAdmin } from '@adula/kit'\nimport { kit } from '#services/kit'\nimport { actorId, knex, mutate, positiveId, wantsJson } from './support.js'\n\nconst service = () => new RolesAdmin(knex(), kit().registry)\n\nexport default class RolesController {\n async index(ctx: HttpContext) {\n const roles = await service().list()\n if (wantsJson(ctx)) return { data: roles }\n return ctx.inertia.render('admin/roles/index', { roles })\n }\n\n async store(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().create(actorId(ctx), {\n name: ctx.request.input('name'),\n permissionLevel: ctx.request.input('permissionLevel'),\n }),\n 'تم إنشاء الدور'\n )\n }\n\n async show(ctx: HttpContext) {\n const role = await service().get(positiveId(ctx.params.id))\n const matrix = service().matrix()\n if (wantsJson(ctx)) return { data: role, matrix }\n return ctx.inertia.render('admin/roles/show', { role, matrix })\n }\n\n async update(ctx: HttpContext) {\n const id = positiveId(ctx.params.id)\n return mutate(\n ctx,\n async () => {\n const { name, permissionLevel } = ctx.request.only(['name', 'permissionLevel'])\n if (name !== undefined) await service().rename(actorId(ctx), id, name)\n if (permissionLevel !== undefined)\n await service().setPermissionLevel(actorId(ctx), id, Number(permissionLevel))\n },\n 'تم تحديث الدور'\n )\n }\n\n async destroy(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().delete(actorId(ctx), positiveId(ctx.params.id)),\n 'تم حذف الدور',\n '/admin/roles'\n )\n }\n\n async setRule(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().setRule(\n actorId(ctx),\n positiveId(ctx.params.id),\n ctx.request.only(['subject', 'action', 'inverted', 'conditions', 'fields'])\n ),\n 'تم حفظ القاعدة'\n )\n }\n\n async removeRule(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().removeRule(actorId(ctx), positiveId(ctx.params.id), positiveId(ctx.params.rule)),\n 'تم حذف القاعدة'\n )\n }\n}\n","app/controllers/admin/settings_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport {\n KitError,\n SETTING_SCOPES,\n SettingsAdmin,\n parseSettingValue,\n type SettingScope,\n} from '@adula/kit'\nimport { actorId, knex, mutate, positiveId, text, wantsJson } from './support.js'\nimport {\n mailTest,\n mailFingerprint,\n sendMailTest,\n publicMailTest,\n} from '#services/mail_delivery_test'\n\nfunction scopeOf(value: unknown): SettingScope {\n const scope = text(value) ?? 'system'\n if (!SETTING_SCOPES.includes(scope as SettingScope))\n throw new KitError(422, 'E_SETTING_SCOPE', 'النطاق غير معروف')\n return scope as SettingScope\n}\n\nexport default class SettingsController {\n async index(ctx: HttpContext) {\n const scope = scopeOf(ctx.request.input('scope'))\n const scopeId = scope === 'system' ? '0' : (text(ctx.request.input('scopeId')) ?? '')\n const settings =\n scope === 'system' || /^\\d{1,18}$/.test(scopeId)\n ? await new SettingsAdmin(knex()).list(scope, scopeId)\n : []\n if (wantsJson(ctx)) return { data: settings, scope, scopeId }\n const user = ctx.auth.getUserOrFail()\n const state = await mailTest().current(user.id, user.email, mailFingerprint())\n return ctx.inertia.render('admin/settings/index', {\n settings,\n scope,\n scopeId,\n mailTest: publicMailTest(state),\n mailRecipient: user.email,\n })\n }\n\n async testMail(ctx: HttpContext) {\n const user = ctx.auth.getUserOrFail()\n return mutate(\n ctx,\n () =>\n mailTest().send(user.id, user.email, mailFingerprint(), (id) =>\n sendMailTest(user.email, id)\n ),\n 'قُبل طلب الإرسال. تحقق من بريدك وأكد وصول الرسالة.',\n ctx.request.input('returnTo') === 'setup' ? '/admin/setup' : '/admin/settings'\n )\n }\n\n async confirmMail(ctx: HttpContext) {\n const user = ctx.auth.getUserOrFail()\n return mutate(\n ctx,\n () =>\n mailTest().answer(\n user.id,\n user.email,\n mailFingerprint(),\n ctx.request.input('id'),\n ctx.request.input('received')\n ),\n ctx.request.input('received') === true\n ? 'تم تسجيل تأكيدك بوصول الرسالة'\n : 'تم تسجيل عدم وصول الرسالة. راجع البريد غير المرغوب وإعدادات البريد.',\n ctx.request.input('returnTo') === 'setup' ? '/admin/setup' : '/admin/settings'\n )\n }\n\n async upsert(ctx: HttpContext) {\n return mutate(\n ctx,\n () => {\n const raw = ctx.request.input('value')\n return new SettingsAdmin(knex()).upsert(actorId(ctx), {\n key: ctx.request.input('key'),\n scope: scopeOf(ctx.request.input('scope')),\n scopeId: text(ctx.request.input('scopeId')),\n value: typeof raw === 'string' ? parseSettingValue(raw) : raw,\n })\n },\n 'تم حفظ الإعداد'\n )\n }\n\n async destroy(ctx: HttpContext) {\n return mutate(\n ctx,\n () => new SettingsAdmin(knex()).delete(actorId(ctx), positiveId(ctx.params.id)),\n 'تم حذف الإعداد'\n )\n }\n}\n","app/controllers/admin/setup_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { KitError } from '@adula/kit'\nimport { actorId, mutate, wantsJson } from './support.js'\nimport {\n setup,\n setupSnapshot,\n identityFingerprint,\n storageFingerprint,\n infrastructureFingerprint,\n probeStorage,\n probeInfrastructure,\n} from '#services/initial_setup'\n\nexport default class SetupController {\n async index(ctx: HttpContext) {\n const state = await setupSnapshot(ctx.auth.getUserOrFail())\n if (wantsJson(ctx)) return state\n return ctx.inertia.render('admin/setup/index', state)\n }\n async check(ctx: HttpContext) {\n return mutate(\n ctx,\n async () => {\n const name = ctx.params.service\n if (name !== 'storage' && name !== 'infrastructure')\n throw new KitError(422, 'E_SETUP_SERVICE', 'الخدمة غير معروفة')\n await setup().check(\n actorId(ctx),\n name,\n name === 'storage' ? storageFingerprint() : infrastructureFingerprint(),\n name === 'storage' ? probeStorage : probeInfrastructure\n )\n },\n 'نجح فحص الخدمة',\n '/admin/setup'\n )\n }\n async confirmIdentity(ctx: HttpContext) {\n return mutate(\n ctx,\n async () => {\n if (ctx.request.input('confirmed') !== true)\n throw new KitError(422, 'E_SETUP_CONFIRM', 'تأكيد الهوية مطلوب')\n await setup().acknowledgeIdentity(actorId(ctx), await identityFingerprint())\n },\n 'تم اعتماد الهوية الحالية',\n '/admin/setup'\n )\n }\n async notification(ctx: HttpContext) {\n return mutate(\n ctx,\n () => setup().testNotification(actorId(ctx)),\n 'أُنشئ الإشعار التجريبي. افتح الإشعارات وحدده كمقروء.',\n '/admin/setup'\n )\n }\n}\n","app/controllers/admin/support.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport db from '@adonisjs/lucid/services/db'\nimport { KitError } from '@adula/kit'\n\nexport const knex = () => db.connection().getWriteClient()\nexport const actorId = (ctx: HttpContext) => ctx.auth.getUserOrFail().id\nexport const wantsJson = (ctx: HttpContext) => ctx.request.accepts(['html', 'json']) === 'json'\nexport const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined)\n\nexport function positiveId(value: unknown, message = 'السجل غير موجود') {\n const id = Number(value)\n if (!Number.isSafeInteger(id) || id <= 0) throw new KitError(404, 'E_NOT_FOUND', message)\n return id\n}\nexport function optionalId(value: unknown) {\n if (value === undefined || value === null || value === '') return null\n return positiveId(value, 'المعرّف غير صالح')\n}\n\n/** API clients get JSON; Inertia forms get a flash and a redirect so the page re-renders. */\nexport async function mutate(\n ctx: HttpContext,\n run: () => Promise<unknown>,\n success: string,\n redirectTo?: string\n) {\n try {\n const data = await run()\n if (wantsJson(ctx)) return { data: data ?? true }\n ctx.session.flash('success', success)\n return redirectTo ? ctx.response.redirect(redirectTo) : ctx.response.redirect().back()\n } catch (error) {\n if (wantsJson(ctx)) throw error\n const code = (error as { code?: string })?.code\n const message =\n error instanceof KitError\n ? error.message\n : code === '23505'\n ? 'هذه القيمة مستخدمة في سجل آخر'\n : code === '23503'\n ? 'تحقق من السجلات المرتبطة'\n : undefined\n if (!message) throw error\n ctx.session.flash('error', message)\n return ctx.response.redirect().back()\n }\n}\n","app/controllers/admin/users_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { KitError, OrgUnitsAdmin, RolesAdmin, UsersAdmin, logActivity } from '@adula/kit'\nimport User from '#models/user'\nimport { kit } from '#services/kit'\nimport { revokeUserSessions } from '#services/sessions'\nimport { IMPERSONATOR_KEY } from '#middleware/admin_middleware'\nimport { actorId, knex, mutate, optionalId, positiveId, text, wantsJson } from './support.js'\n\nconst service = () => new UsersAdmin(knex())\nconst RESOURCE = 'core.users'\n\nexport default class UsersController {\n async index(ctx: HttpContext) {\n const search = text(ctx.request.input('search')) ?? ''\n const users = await service().list({\n search,\n cursor: optionalId(ctx.request.input('cursor')) ?? undefined,\n limit: ctx.request.input('limit'),\n })\n if (wantsJson(ctx)) return users\n return ctx.inertia.render('admin/users/index', { users, search })\n }\n\n async show(ctx: HttpContext) {\n const user = await service().get(positiveId(ctx.params.id))\n const roles = await new RolesAdmin(knex(), kit().registry).list()\n const orgUnits = await new OrgUnitsAdmin(knex(), kit().registry).tree()\n const props = { user, roles, orgUnits }\n if (wantsJson(ctx)) return props\n return ctx.inertia.render('admin/users/show', props)\n }\n\n async assignRole(ctx: HttpContext) {\n const id = positiveId(ctx.params.id)\n return mutate(\n ctx,\n () =>\n service().assignRole(\n actorId(ctx),\n id,\n positiveId(ctx.request.input('roleId'), 'الدور غير موجود'),\n optionalId(ctx.request.input('orgUnitId'))\n ),\n 'تم إسناد الدور'\n )\n }\n\n async removeRole(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().removeRole(\n actorId(ctx),\n positiveId(ctx.params.id),\n positiveId(ctx.params.assignment)\n ),\n 'تمت إزالة الدور'\n )\n }\n\n async assignOrgUnit(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().assignOrgUnit(\n actorId(ctx),\n positiveId(ctx.params.id),\n positiveId(ctx.request.input('orgUnitId'), 'الوحدة التنظيمية غير موجودة')\n ),\n 'تمت إضافة المستخدم إلى الوحدة'\n )\n }\n\n async removeOrgUnit(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().removeOrgUnit(\n actorId(ctx),\n positiveId(ctx.params.id),\n positiveId(ctx.params.orgUnit)\n ),\n 'تمت إزالة العضوية'\n )\n }\n\n async disable(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().disable(actorId(ctx), positiveId(ctx.params.id)),\n 'تم تعطيل الحساب'\n )\n }\n\n async enable(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().enable(actorId(ctx), positiveId(ctx.params.id)),\n 'تم تفعيل الحساب'\n )\n }\n\n async revokeSessions(ctx: HttpContext) {\n const id = positiveId(ctx.params.id)\n const admin = actorId(ctx)\n return mutate(\n ctx,\n async () => {\n await service().get(id)\n const count = await revokeUserSessions(id, admin, {\n except: id === admin ? ctx.session.sessionId : undefined,\n })\n await logActivity(knex(), {\n resource: RESOURCE,\n recordId: id,\n actorId: admin,\n action: 'revoke_sessions',\n changes: { count },\n })\n return { count }\n },\n 'تم إنهاء جلسات المستخدم'\n )\n }\n\n /** The administrator keeps their identity in the session so the target user cannot inherit it. */\n async impersonate(ctx: HttpContext) {\n const id = positiveId(ctx.params.id)\n const admin = actorId(ctx)\n return mutate(\n ctx,\n async () => {\n if (id === admin) throw new KitError(422, 'E_SELF_IMPERSONATE', 'لا يمكنك انتحال حسابك')\n if (ctx.session.get(IMPERSONATOR_KEY))\n throw new KitError(422, 'E_ALREADY_IMPERSONATING', 'أنهِ الانتحال الحالي أولاً')\n const target = await service().get(id)\n if (target.disabledAt)\n throw new KitError(422, 'E_USER_DISABLED', 'لا يمكن انتحال حساب معطّل')\n const user = await User.findOrFail(id)\n await logActivity(knex(), {\n resource: RESOURCE,\n recordId: id,\n actorId: admin,\n action: 'impersonate',\n })\n ctx.session.put(IMPERSONATOR_KEY, admin)\n await ctx.auth.use('web').login(user)\n return { id }\n },\n 'أنت الآن تتصفح باسم المستخدم',\n '/'\n )\n }\n\n async stopImpersonation(ctx: HttpContext) {\n const current = actorId(ctx)\n return mutate(\n ctx,\n async () => {\n const impersonator = Number(ctx.session.get(IMPERSONATOR_KEY))\n if (!impersonator) throw new KitError(422, 'E_NOT_IMPERSONATING', 'لا يوجد انتحال نشط')\n const original = await User.find(impersonator)\n if (!original) throw new KitError(404, 'E_USER_NOT_FOUND', 'المستخدم غير موجود')\n ctx.session.forget(IMPERSONATOR_KEY)\n await ctx.auth.use('web').login(original)\n await logActivity(knex(), {\n resource: RESOURCE,\n recordId: current,\n actorId: impersonator,\n action: 'stop_impersonation',\n })\n return { id: impersonator }\n },\n 'عدت إلى حسابك',\n `/admin/users/${current}`\n )\n }\n}\n","app/controllers/admin_sessions_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { Exception } from '@adonisjs/core/exceptions'\nimport { buildAbility } from '@adula/kit'\nimport { kit } from '#services/kit'\nimport { listActiveSessions, revokeSession } from '#services/sessions'\n\n/** Every live session across users; only actors who can manage everything. */\nexport default class AdminSessionsController {\n async index(ctx: HttpContext) {\n await this.authorize(ctx)\n return ctx.inertia.render('admin/sessions/index', {\n sessions: await listActiveSessions(),\n currentSessionId: ctx.session.sessionId,\n })\n }\n\n async destroy(ctx: HttpContext) {\n const actor = await this.authorize(ctx)\n const revoked = await revokeSession(String(ctx.params.id), actor.id)\n if (revoked) ctx.session.flash('success', 'أُنهيت الجلسة وسيُطلب من صاحبها الدخول مجدداً.')\n else ctx.session.flash('error', 'الجلسة غير موجودة أو أُنهيت من قبل.')\n return ctx.response.redirect().toRoute('admin_sessions.index')\n }\n\n private async authorize({ auth }: HttpContext) {\n const user = auth.getUserOrFail()\n const actor = await kit().actors.load(user.id)\n const ability = buildAbility(actor.rules, kit().registry.all())\n if (!ability.can('manage', 'all'))\n throw new Exception('غير مصرح لك بالوصول إلى هذه الصفحة', {\n status: 403,\n code: 'E_FORBIDDEN',\n })\n return user\n }\n}\n","app/controllers/attachments_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { randomUUID } from 'node:crypto'\nimport attachmentManager from '@jrmc/adonis-attachment/services/main'\nimport drive from '@adonisjs/drive/services/main'\nimport db from '@adonisjs/lucid/services/db'\nimport env from '#start/env'\nimport { kit } from '#services/kit'\nimport { KitError, attachmentUrl, buildAbility, findAttachment, registerUpload } from '@adula/kit'\nimport type { Actor, AttachmentRecord } from '@adula/kit'\n\nconst notFound = () => new KitError(404, 'E_NOT_FOUND', 'المرفق غير موجود')\n\n/** RFC 5987: an ASCII fallback plus the UTF-8 name so Arabic titles survive every browser. */\nfunction contentDisposition(name: string) {\n const fallback = name.replace(/[^\\x20-\\x7e]/g, '_').replace(/[\"\\\\]/g, '_') || 'file'\n const encoded = encodeURIComponent(name).replace(\n /[!'()*]/g,\n (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`\n )\n return `attachment; filename=\"${fallback}\"; filename*=UTF-8''${encoded}`\n}\n\nexport default class AttachmentsController {\n async store({ auth, request, response }: HttpContext) {\n const runtime = kit()\n const actor = await runtime.actors.load(auth.getUserOrFail().id)\n const resourceName = request.input('resource')\n const fieldName = request.input('field')\n if (\n typeof resourceName !== 'string' ||\n !runtime.registry.all().some((entry) => entry.name === resourceName)\n )\n throw new KitError(404, 'E_NOT_FOUND', 'الكيان غير موجود')\n const resource = runtime.registry.get(resourceName)\n const field = typeof fieldName === 'string' ? resource.fields[fieldName] : undefined\n if (!field || field.type !== 'attachment' || !resource.form.includes(fieldName))\n throw new KitError(422, 'E_FIELD_INVALID', 'الحقل ليس حقل مرفقات')\n const ability = buildAbility(actor.rules, runtime.registry.all())\n const level = Math.max(field.permissionLevel ?? 0, resource.hidden?.includes(fieldName) ? 1 : 0)\n const allowed =\n actor.permissionLevel >= level &&\n (['create', 'update'] as const).some(\n (action) =>\n resource.actions.includes(action) &&\n ability.can(action, resource.name) &&\n ability.can(action, resource.name, fieldName)\n )\n if (!allowed) throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لرفع مرفق لهذا الحقل')\n const file = request.file('file', { size: '20mb' })\n if (!file) throw new KitError(422, 'E_FILE_REQUIRED', 'اختر ملفاً للرفع')\n if (!file.isValid)\n throw new KitError(\n 422,\n 'E_FILE_INVALID',\n file.errors.map((error) => error.message).join('، ')\n )\n const extname = (file.extname ?? '').toLowerCase()\n const safeExtname = /^[a-z0-9]{1,32}$/.test(extname) ? extname : 'bin'\n const originalName =\n (file.clientName.split(/[\\\\/]/).pop() || '').slice(0, 255) || `file.${safeExtname}`\n const disk = env.get('DRIVE_DISK')\n const attachment = await attachmentManager.createFromFile(file)\n attachment.name = `${randomUUID()}.${safeExtname}`\n attachment.setOptions({ folder: `resources/${resource.name}/${fieldName}`, disk })\n await attachmentManager.write(attachment)\n const path = (attachment.path ?? '').replaceAll('\\\\', '/')\n try {\n const row = await registerUpload(db.connection().getWriteClient(), {\n disk,\n path,\n name: attachment.name,\n originalName,\n size: attachment.size,\n mimeType: attachment.mimeType || 'application/octet-stream',\n extname: safeExtname,\n data: { ...attachment.toObject(), path },\n uploadedBy: actor.id,\n resource: resource.name,\n field: fieldName,\n })\n return response.created({\n data: {\n id: row.id,\n name: row.originalName,\n size: row.size,\n mimeType: row.mimeType,\n url: attachmentUrl(row.id),\n },\n })\n } catch (error) {\n await attachmentManager.remove(attachment)\n throw error\n }\n }\n\n async show({ auth, params, response }: HttpContext) {\n const runtime = kit()\n const actor = await runtime.actors.load(auth.getUserOrFail().id)\n const id = Number(params.id)\n const row = await findAttachment(\n db.connection().getWriteClient(),\n /^\\d+$/.test(String(params.id)) ? id : undefined\n )\n if (!row) throw notFound()\n await this.#authorize(row, actor, runtime)\n const disk = drive.use(row.disk as never)\n if (!(await disk.exists(row.path)))\n throw new KitError(500, 'E_ATTACHMENT_FILE_MISSING', 'ملف المرفق غير متاح على وحدة التخزين')\n response.header('Content-Type', row.mimeType)\n response.header('Content-Length', String(row.size))\n response.header('Content-Disposition', contentDisposition(row.originalName))\n response.header('X-Content-Type-Options', 'nosniff')\n response.header('Cache-Control', 'private, no-store')\n return response.stream(await disk.getStream(row.path))\n }\n\n /** Bound files inherit the record's view policy; unbound uploads belong to their uploader only. */\n async #authorize(row: AttachmentRecord, actor: Actor, runtime: ReturnType<typeof kit>) {\n if (row.recordId === null || row.resource === null || row.field === null) {\n if (row.uploadedBy !== actor.id) throw notFound()\n return\n }\n if (!runtime.registry.all().some((entry) => entry.name === row.resource)) throw notFound()\n let shown\n try {\n shown = await runtime.resources.show(row.resource, row.recordId, actor)\n } catch (error) {\n if (error instanceof KitError && [403, 404].includes(error.status)) throw notFound()\n throw error\n }\n const value = shown.data[row.field]\n if (\n !value ||\n typeof value !== 'object' ||\n Array.isArray(value) ||\n Number((value as { id?: unknown }).id) !== row.id\n )\n throw notFound()\n }\n}\n","app/controllers/new_account_controller.ts":"import User from '#models/user'\nimport { signupValidator } from '#validators/user'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { recordSession } from '#services/sessions'\n\nexport default class NewAccountController {\n async create({ inertia }: HttpContext) {\n return inertia.render('auth/signup', {})\n }\n\n async store(ctx: HttpContext) {\n const { request, response, auth } = ctx\n const { passwordConfirmation, ...payload } = await request.validateUsing(signupValidator)\n const user = await User.create({ ...payload })\n\n await auth.use('web').login(user)\n await recordSession(ctx, user.id)\n await logAuthActivity({\n userId: user.id,\n action: 'login',\n changes: { ...requestContext(ctx), via: 'signup' },\n })\n response.redirect().toRoute('home')\n }\n}\n","app/controllers/oauth_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { Exception } from '@adonisjs/core/exceptions'\nimport { isSocialProvider, linkOrCreateSocialUser } from '#services/social_accounts'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { recordSession } from '#services/sessions'\nimport { Settings } from '@adula/kit'\nimport db from '@adonisjs/lucid/services/db'\nimport { oauthFingerprint } from '#services/initial_setup'\n\n/** Unknown or unconfigured providers behave like a missing route. */\nconst notFound = () =>\n new Exception('الصفحة غير موجودة', { status: 404, code: 'E_ROUTE_NOT_FOUND' })\n\nexport default class OauthController {\n async redirect({ params, ally }: HttpContext) {\n const provider: unknown = params.provider\n if (!isSocialProvider(provider)) throw notFound()\n return ally.use(provider).redirect()\n }\n\n async callback(ctx: HttpContext) {\n const { params, ally, auth, response, session } = ctx\n const provider: unknown = params.provider\n if (!isSocialProvider(provider)) throw notFound()\n const driver = ally.use(provider)\n const fail = (message: string) => {\n session.flash('error', message)\n return response.redirect().toRoute('session.create')\n }\n if (driver.accessDenied()) return fail('ألغيت تسجيل الدخول قبل منح الإذن.')\n if (driver.stateMisMatch()) return fail('انتهت صلاحية طلب تسجيل الدخول. حاول مجدداً.')\n if (driver.hasError()) return fail(`تعذّر تسجيل الدخول عبر المزوّد: ${driver.getError()}`)\n\n const profile = await driver.user()\n if (!profile.email || profile.emailVerificationState !== 'verified')\n return fail('يتطلب الدخول بريداً إلكترونياً موثقاً لدى المزوّد.')\n const { user, created } = await linkOrCreateSocialUser({\n provider,\n providerId: String(profile.id),\n email: profile.email,\n name: profile.name || profile.nickName || null,\n })\n if (user.disabledAt) return fail('هذا الحساب معطّل. تواصل مع مدير النظام.')\n\n await auth.use('web').login(user)\n await recordSession(ctx, user.id)\n await new Settings(db.connection().getWriteClient()).set(`setup.oauth.${provider}`, {\n fingerprint: oauthFingerprint(provider),\n at: new Date().toISOString(),\n })\n await logAuthActivity({\n userId: user.id,\n action: 'oauth_login',\n changes: { ...requestContext(ctx), provider, created },\n })\n return response.redirect().toRoute('home')\n }\n}\n","app/controllers/password_reset_controller.ts":"import { createHash, randomBytes } from 'node:crypto'\nimport env from '#start/env'\nimport User from '#models/user'\nimport db from '@adonisjs/lucid/services/db'\nimport mail from '@adonisjs/mail/services/main'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport PasswordResetNotification from '#mails/password_reset_notification'\nimport { forgotPasswordValidator, resetPasswordValidator } from '#validators/user'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { revokeUserSessions } from '#services/sessions'\n\n/** Recovery links expire after one hour and can be used once. */\nconst TOKEN_TTL_MS = 60 * 60 * 1000\nconst hashToken = (token: string) => createHash('sha256').update(token).digest('hex')\nconst knex = () => db.connection().getWriteClient()\n\n/** The pending token row, or null when unknown, already used or expired. */\nasync function pendingToken(token: unknown) {\n if (typeof token !== 'string' || token.length < 16 || token.length > 128) return null\n const row = await knex()('password_reset_tokens')\n .where({ token_hash: hashToken(token) })\n .first()\n if (!row || row.used_at || new Date(row.expires_at).getTime() < Date.now()) return null\n return row\n}\n\nexport default class PasswordResetController {\n async forgot({ inertia }: HttpContext) {\n return inertia.render('auth/forgot', {})\n }\n\n /** Always answers the same way so the form cannot be used to probe e-mails. */\n async send(ctx: HttpContext) {\n const { request, response, session } = ctx\n const { email } = await request.validateUsing(forgotPasswordValidator)\n const user = await User.query().whereRaw('lower(email) = lower(?)', [email]).first()\n if (user && !user.disabledAt) {\n const token = randomBytes(32).toString('base64url')\n await knex()('password_reset_tokens')\n .where({ user_id: user.id })\n .whereNull('used_at')\n .delete()\n await knex()('password_reset_tokens').insert({\n user_id: user.id,\n token_hash: hashToken(token),\n expires_at: new Date(Date.now() + TOKEN_TTL_MS),\n })\n let accepted = false\n try {\n await mail.send(\n new PasswordResetNotification(user, `${env.get('APP_URL')}/password/reset/${token}`)\n )\n accepted = true\n } catch {\n // Revoke only this attempt, preserving any newer concurrent request.\n await knex()('password_reset_tokens')\n .where({ token_hash: hashToken(token) })\n .delete()\n }\n await logAuthActivity({\n userId: user.id,\n action: accepted ? 'password_reset_requested' : 'password_reset_delivery_failed',\n changes: requestContext(ctx),\n })\n }\n session.flash(\n 'success',\n 'إن كان البريد مسجلاً لدينا فستصلك رسالة تحوي رابط إعادة التعيين خلال دقائق.'\n )\n return response.redirect().toRoute('password_reset.forgot')\n }\n\n async reset({ inertia, params }: HttpContext) {\n const token = String(params.token)\n return inertia.render('auth/reset', { token, valid: Boolean(await pendingToken(token)) })\n }\n\n async update(ctx: HttpContext) {\n const { request, response, session, params } = ctx\n const { password } = await request.validateUsing(resetPasswordValidator)\n const row = await pendingToken(params.token)\n const user = row ? await User.find(row.user_id) : null\n // The conditional update makes the token single-use even under concurrent submits.\n const consumed = row\n ? await knex()('password_reset_tokens')\n .where({ id: row.id })\n .whereNull('used_at')\n .update({ used_at: knex().fn.now() })\n : 0\n if (!row || !user || user.disabledAt || !consumed) {\n session.flash('error', 'رابط إعادة التعيين غير صالح أو انتهت صلاحيته. اطلب رابطاً جديداً.')\n return response.redirect().toRoute('password_reset.forgot')\n }\n\n user.password = password\n await user.save()\n await revokeUserSessions(user.id, user.id)\n await logAuthActivity({\n userId: user.id,\n action: 'password_reset',\n changes: requestContext(ctx),\n })\n session.flash('success', 'تم تغيير كلمة المرور. سجّل الدخول بكلمة المرور الجديدة.')\n return response.redirect().toRoute('session.create')\n }\n}\n","app/controllers/profile_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { changePasswordValidator, profileValidator } from '#validators/user'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { revokeUserSessions } from '#services/sessions'\n\nexport default class ProfileController {\n async show({ inertia }: HttpContext) {\n return inertia.render('account/profile', {})\n }\n\n async update(ctx: HttpContext) {\n const { request, auth, response, session } = ctx\n const { fullName } = await request.validateUsing(profileValidator)\n const user = auth.getUserOrFail()\n const previous = user.fullName\n user.fullName = fullName\n await user.save()\n await logAuthActivity({\n userId: user.id,\n action: 'profile_updated',\n changes: { ...requestContext(ctx), fields: ['fullName'], previous: { fullName: previous } },\n })\n session.flash('success', 'تم حفظ بيانات الملف الشخصي.')\n return response.redirect().toRoute('profile.show')\n }\n\n /** Changing the password ends every other session of the user. */\n async password(ctx: HttpContext) {\n const { request, auth, response, session } = ctx\n const { currentPassword, password } = await request.validateUsing(changePasswordValidator)\n const user = auth.getUserOrFail()\n if (!(await user.verifyPassword(currentPassword))) {\n session.flash('inputErrorsBag', { currentPassword: ['كلمة المرور الحالية غير صحيحة'] })\n return response.redirect().toRoute('profile.show')\n }\n user.password = password\n await user.save()\n await revokeUserSessions(user.id, user.id, { except: session.sessionId })\n await logAuthActivity({\n userId: user.id,\n action: 'password_changed',\n changes: requestContext(ctx),\n })\n session.flash('success', 'تم تغيير كلمة المرور وإنهاء الجلسات الأخرى.')\n return response.redirect().toRoute('profile.show')\n }\n}\n","app/controllers/resources_controller.ts":"import { existsSync, readFileSync, readdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport app from '@adonisjs/core/services/app'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { createResourceController } from '@adula/kit'\nimport type { Actor, ResourceDescription } from '@adula/kit'\nimport { kit } from '#services/kit'\n\ntype Mode = 'index' | 'form' | 'show'\nconst modes: Mode[] = ['index', 'form', 'show']\n\n/**\n * Presence of `inertia/pages/<resource>/<mode>.tsx` replaces the generated page.\n * Scanned once at boot: sources in development, the Vite manifest in production builds.\n */\nfunction discoverPageOverrides() {\n const found = new Set<string>()\n const roots = [app.makePath('inertia/pages')]\n for (const pages of roots) {\n if (!existsSync(pages)) continue\n for (const entry of readdirSync(pages, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue\n for (const mode of modes)\n if (existsSync(join(pages, entry.name, `${mode}.tsx`))) found.add(`${entry.name}/${mode}`)\n }\n }\n if (roots.some((path) => existsSync(path))) return found\n const manifest = app.publicPath('assets/.vite/manifest.json')\n if (!existsSync(manifest)) return found\n for (const key of Object.keys(JSON.parse(readFileSync(manifest, 'utf8')))) {\n const match = /^inertia\\/pages\\/([^/]+)\\/(index|form|show)\\.tsx$/.exec(key)\n if (match) found.add(`${match[1]}/${match[2]}`)\n }\n return found\n}\nconst overrides = discoverPageOverrides()\nexport function pageFor(resource: string, mode: Mode) {\n return overrides.has(`${resource}/${mode}`) ? `${resource}/${mode}` : 'resources/page'\n}\n/** Override pages receive the generic payload; the cast only widens the page name resolved at boot. */\nconst generic = (page: string) => page as 'resources/page'\n\nasync function actorOf(ctx: HttpContext) {\n return kit().actors.load(ctx.auth.getUserOrFail().id)\n}\n\nfunction childDescriptions(description: ResourceDescription, actor: Actor) {\n const children: Record<string, ResourceDescription> = {}\n for (const field of description.fields) {\n if (field.type !== 'hasMany') continue\n try {\n children[field.key] = kit().resources.describe(field.resource, actor)\n } catch {\n // A child the actor cannot view is omitted; the deferred rows are authorized separately.\n }\n }\n return children\n}\n\nexport default createResourceController(\n async (ctx) => {\n const runtime = kit()\n return { ...runtime, actor: await runtime.actors.load(ctx.auth.getUserOrFail().id) }\n },\n async (ctx, resource, result) => {\n const runtime = kit()\n const actor = await actorOf(ctx)\n const page = pageFor(resource.name, 'index')\n return ctx.inertia.render(generic(page), {\n view: async () => ({\n mode: 'index' as const,\n resource: runtime.resources.describe(resource.name, actor),\n lookups: await runtime.resources.lookups(resource.name, actor),\n savedViews: await runtime.savedViews.list(resource.name, actor),\n }),\n result: ctx.inertia\n .scroll(result, (value) => ({\n pageName: 'cursor',\n currentPage: (ctx.request.input('cursor') as string | undefined) ?? null,\n nextPage: value.meta.nextCursor,\n previousPage: null,\n }))\n .matchOn('id'),\n })\n },\n async (ctx, resource, editor) => {\n const page = pageFor(resource.name, 'form')\n const actor = await actorOf(ctx)\n const shown = editor.record\n ? await kit().resources.show(resource.name, Number(editor.record.id), actor)\n : null\n const permissions = shown ? shown.permissions : {}\n return ctx.inertia.render(generic(page), {\n view: { mode: 'form', editor, permissions },\n })\n },\n async (ctx, resource, result) => {\n const runtime = kit()\n const actor = await actorOf(ctx)\n const id = Number(result.data.id)\n const description = runtime.resources.describe(resource.name, actor)\n return ctx.inertia.render(generic(pageFor(resource.name, 'show')), {\n view: async () => ({\n mode: 'show' as const,\n resource: description,\n result,\n lookups: await runtime.resources.lookups(resource.name, actor),\n childResources: childDescriptions(description, actor),\n }),\n childrenData: ctx.inertia.defer(\n () => runtime.resources.children(resource.name, id, actor),\n 'children'\n ),\n activity: ctx.inertia.defer(\n () => runtime.resources.activity(resource.name, id, actor),\n 'activity'\n ),\n })\n }\n)\n","app/controllers/saved_views_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { KitError, type Actor as KitActor } from '@adula/kit'\nimport { kit } from '#services/kit'\n\n/** Saved views are per-actor list presets; the kit validates every query key. */\nexport default class SavedViewsController {\n async store(ctx: HttpContext) {\n return this.#run(ctx, async (actor, resource) => ({\n data: await kit().savedViews.save(resource, actor, {\n name: ctx.request.input('name'),\n query: ctx.request.input('query'),\n shared: ctx.request.input('shared') === true,\n }),\n }))\n }\n\n async destroy(ctx: HttpContext) {\n return this.#run(ctx, async (actor, resource) => {\n const id = Number(ctx.params.id)\n if (!Number.isSafeInteger(id) || id <= 0)\n throw new KitError(404, 'E_VIEW_NOT_FOUND', 'العرض المحفوظ غير موجود')\n await kit().savedViews.remove(resource, actor, id)\n return { data: { id } }\n })\n }\n\n async #run(ctx: HttpContext, action: (actor: KitActor, resource: string) => Promise<unknown>) {\n try {\n const actor = await kit().actors.load(ctx.auth.getUserOrFail().id)\n return await action(actor, ctx.params.resource)\n } catch (error) {\n if (error instanceof KitError)\n return ctx.response\n .status(error.status)\n .send({ error: { code: error.code, message: error.message } })\n throw error\n }\n }\n}\n","app/controllers/session_controller.ts":"import User from '#models/user'\nimport { loginValidator } from '#validators/user'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { errors as authErrors } from '@adonisjs/auth'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { endSession, recordSession } from '#services/sessions'\nimport { socialProviders } from '#services/social_accounts'\n\nexport default class SessionController {\n async create({ inertia }: HttpContext) {\n return inertia.render('auth/login', { socialProviders: socialProviders() })\n }\n\n async store(ctx: HttpContext) {\n const { request, auth, response } = ctx\n const { email, password } = await request.validateUsing(loginValidator)\n let user: User\n try {\n user = await User.verifyCredentials(email, password)\n } catch (error) {\n if (!(error instanceof authErrors.E_INVALID_CREDENTIALS)) throw error\n // Unknown e-mails cannot be logged (activities reference users); the limiter counts them.\n const known = await User.findBy('email', email)\n if (known)\n await logAuthActivity({\n userId: known.id,\n action: 'login_failed',\n changes: requestContext(ctx),\n })\n return this.refuse(ctx, 'البريد الإلكتروني أو كلمة المرور غير صحيحة')\n }\n if (user.disabledAt) {\n await logAuthActivity({\n userId: user.id,\n action: 'login_failed',\n changes: { ...requestContext(ctx), reason: 'disabled' },\n })\n return this.refuse(ctx, 'هذا الحساب معطّل. تواصل مع مدير النظام.')\n }\n\n await auth.use('web').login(user)\n await recordSession(ctx, user.id)\n await logAuthActivity({ userId: user.id, action: 'login', changes: requestContext(ctx) })\n response.redirect().toRoute('home')\n }\n\n async destroy(ctx: HttpContext) {\n const { auth, response, session } = ctx\n const user = auth.getUserOrFail()\n const sessionId = session.sessionId\n await auth.use('web').logout()\n await endSession(sessionId)\n await logAuthActivity({ userId: user.id, action: 'logout', changes: requestContext(ctx) })\n response.redirect().toRoute('session.create')\n }\n\n private refuse({ request, response, session }: HttpContext, message: string) {\n if (request.accepts(['html', 'json']) === 'json')\n return response.unauthorized({ errors: [{ message }] })\n session.flashExcept(['password'])\n session.flash('inputErrorsBag', { email: [message] })\n return response.redirect().toRoute('session.create')\n }\n}\n","app/controllers/user_invitations_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { KitError, UserInvitations } from '@adula/kit'\nimport db from '@adonisjs/lucid/services/db'\nimport hash from '@adonisjs/core/services/hash'\nimport mail from '@adonisjs/mail/services/main'\nimport env from '#start/env'\nimport { ValidationError } from '@vinejs/vine'\nimport UserInvitationNotification from '#mails/user_invitation_notification'\nimport { invitationValidator, acceptInvitationValidator } from '#validators/user'\n\nconst service = () => new UserInvitations(db.connection().getWriteClient())\n\nexport default class UserInvitationsController {\n async create(ctx: HttpContext) {\n if (!(await service().canInvite(ctx.auth.getUserOrFail().id)))\n throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية دعوة المستخدمين')\n return ctx.inertia.render('users/invite', {})\n }\n\n async store(ctx: HttpContext) {\n const id = ctx.auth.getUserOrFail().id\n if (!(await service().canInvite(id)))\n throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية دعوة المستخدمين')\n try {\n const input = await ctx.request.validateUsing(invitationValidator)\n const data = await service().invite(id, input, async ({ email, token }) => {\n await mail.send(\n new UserInvitationNotification(\n email,\n `${env.get('APP_URL').replace(/\\/$/, '')}/invitations/${token}`\n )\n )\n })\n if (ctx.request.accepts(['html', 'json']) === 'json') return { data }\n ctx.session.flash(\n 'success',\n 'تم إرسال الدعوة بالبريد. يمكن إعادة إرسالها بعد دقيقة؛ عندها يُلغى الرابط السابق.'\n )\n return ctx.response.redirect('/users/invite')\n } catch (error) {\n return this.failure(ctx, error)\n }\n }\n\n async show(ctx: HttpContext) {\n const token = String(ctx.params.token)\n ctx.response.header('Referrer-Policy', 'no-referrer').header('Cache-Control', 'no-store')\n return ctx.inertia.render('auth/invitation', { token, valid: await service().valid(token) })\n }\n\n async accept(ctx: HttpContext) {\n try {\n const { password } = await ctx.request.validateUsing(acceptInvitationValidator)\n const data = await service().accept(String(ctx.params.token), password, (value) =>\n hash.make(value)\n )\n if (ctx.request.accepts(['html', 'json']) === 'json') return { data }\n ctx.session.flash(\n 'success',\n 'تم إنشاء حسابك. سجّل الدخول بكلمة مرورك الجديدة؛ يعيّن المسؤول صلاحيات العمل.'\n )\n return ctx.response.redirect('/login')\n } catch (error) {\n return this.failure(ctx, error)\n }\n }\n\n private failure(ctx: HttpContext, error: unknown) {\n if (ctx.request.accepts(['html', 'json']) === 'json') throw error\n if (error instanceof ValidationError) {\n const messages = Array.isArray(error.messages)\n ? Object.fromEntries(\n error.messages.map((entry: { field: string; message: string }) => [\n entry.field,\n entry.message,\n ])\n )\n : error.messages\n ctx.session.flash('inputErrorsBag', messages)\n } else if (error instanceof KitError) {\n ctx.session.flash('inputErrorsBag', { form: error.message })\n } else throw error\n // Bearer-token pages deliberately omit Referer; redirect to the known GET route explicitly.\n return ctx.response.redirect(ctx.request.url())\n }\n}\n","app/exceptions/handler.ts":"import app from '@adonisjs/core/services/app'\nimport { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'\nimport type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http'\nimport { KitError } from '@adula/kit'\n\nexport default class HttpExceptionHandler extends ExceptionHandler {\n /**\n * In debug mode, the exception handler will display verbose errors\n * with pretty printed stack traces.\n */\n protected debug = !app.inProduction\n\n /**\n * Status pages are used to display a custom HTML pages for certain error\n * codes. You might want to enable them in production only, but feel\n * free to enable them in development as well.\n */\n protected renderStatusPages = app.inProduction\n\n /**\n * Status pages is a collection of error code range and a callback\n * to return the HTML contents to send as a response.\n */\n protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {\n '404': (_, { inertia }) => inertia.render('errors/not_found', {}),\n '500..599': (_, { inertia }) => inertia.render('errors/server_error', {}),\n }\n\n /**\n * The method is used for handling errors and returning\n * response to the client\n */\n async handle(error: unknown, ctx: HttpContext) {\n if (\n (error as { code?: string })?.code === 'E_BAD_CSRF_TOKEN' &&\n (ctx.request.url() === '/mcp' || ctx.request.accepts(['json', 'html']) === 'json')\n )\n return ctx.response.forbidden({\n error: {\n code: 'E_BAD_CSRF_TOKEN',\n message: 'انتهت صلاحية الطلب. حدّث الصفحة وحاول مجدداً.',\n },\n })\n if (error instanceof KitError)\n return ctx.response\n .status(error.status)\n .send({ error: { code: error.code, message: error.message } })\n if ((error as { code?: string })?.code === '23505')\n return ctx.response.conflict({\n error: { code: 'E_DUPLICATE', message: 'هذه القيمة مستخدمة في سجل آخر' },\n })\n if ((error as { code?: string })?.code === '23503')\n return ctx.response.unprocessableEntity({\n error: { code: 'E_RELATION', message: 'تحقق من السجلات المرتبطة' },\n })\n return super.handle(error, ctx)\n }\n\n /**\n * The method is used to report error to the logging service or\n * the a third party error monitoring service.\n *\n * @note You should not attempt to send a response from this method.\n */\n async report(error: unknown, ctx: HttpContext) {\n return super.report(error, ctx)\n }\n}\n","app/jobs/domain_event_job.ts":"import { Job } from '@nemoventures/adonis-jobs'\nimport db from '@adonisjs/lucid/services/db'\nimport { consumeEvent, type DomainEvent } from '@adula/kit'\nimport { listeners } from '#start/listeners'\n\nexport default class DomainEventJob extends Job<DomainEvent, void> {\n static nameOverride = 'adula.domain_event'\n async process() {\n for (const listener of listeners) {\n await consumeEvent(db.connection().getWriteClient(), listener, this.data)\n }\n }\n}\n","app/mails/password_reset_notification.ts":"import { BaseMail } from '@adonisjs/mail'\n\n/** Recovery e-mail carrying the single-use reset link (valid for one hour). */\nexport default class PasswordResetNotification extends BaseMail {\n subject = 'إعادة تعيين كلمة المرور'\n\n constructor(\n private user: { email: string; fullName: string | null },\n public resetUrl: string\n ) {\n super()\n }\n\n prepare() {\n const greeting = this.user.fullName ? `مرحباً ${this.user.fullName}،` : 'مرحباً،'\n this.message\n .to(this.user.email)\n .html(\n `<div dir=\"rtl\" style=\"font-family: system-ui, sans-serif; line-height: 1.8\">\n <p>${greeting}</p>\n <p>وصلنا طلب لإعادة تعيين كلمة مرور حسابك. اضغط الرابط التالي خلال ساعة واحدة لاختيار كلمة مرور جديدة:</p>\n <p><a href=\"${this.resetUrl}\">${this.resetUrl}</a></p>\n <p>إن لم تطلب ذلك فتجاهل هذه الرسالة؛ كلمة مرورك لن تتغير.</p>\n</div>`\n )\n .text(\n `${greeting}\\n\\nوصلنا طلب لإعادة تعيين كلمة مرور حسابك. افتح الرابط التالي خلال ساعة واحدة لاختيار كلمة مرور جديدة:\\n${this.resetUrl}\\n\\nإن لم تطلب ذلك فتجاهل هذه الرسالة؛ كلمة مرورك لن تتغير.`\n )\n }\n}\n","app/mails/user_invitation_notification.ts":"import { BaseMail } from '@adonisjs/mail'\n\nexport default class UserInvitationNotification extends BaseMail {\n subject = 'دعوة لإنشاء حسابك'\n\n constructor(\n private email: string,\n public invitationUrl: string\n ) {\n super()\n }\n\n prepare() {\n this.message\n .to(this.email)\n .text(\n `مرحبًا،\\n\\nدعاك مسؤول النظام لإنشاء حسابك. افتح الرابط التالي خلال 24 ساعة واختر كلمة مرورك:\\n${this.invitationUrl}\\n\\nالرابط للاستخدام مرة واحدة. إن لم تكن تتوقع هذه الدعوة فتجاهلها. لن يُنشأ الحساب قبل قبول الدعوة.`\n )\n }\n}\n","app/mcp/tools/resource_read_tool.ts":"import { resourceTools } from '#services/mcp'\nexport default resourceTools.ResourceReadTool\n","app/mcp/tools/resource_write_tool.ts":"import { resourceTools } from '#services/mcp'\nexport default resourceTools.ResourceWriteTool\n","app/middleware/admin_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport db from '@adonisjs/lucid/services/db'\nimport { NotificationsAdmin, UserInvitations, buildAbility, isBackupStale } from '@adula/kit'\nimport { kit } from '#services/kit'\n\nexport const IMPERSONATOR_KEY = 'impersonator_id'\n\nexport async function isAdministrator(userId: number) {\n const runtime = kit()\n const actor = await runtime.actors.load(userId)\n return buildAbility(actor.rules, runtime.registry.all()).can('manage', 'all')\n}\n\n/** Shell props for every Inertia page: admin navigation, backup bar, bell and impersonation. */\nexport async function sharedAdminProps(ctx: HttpContext) {\n const { auth, session } = ctx as Partial<HttpContext>\n const userId = auth?.user?.id\n const knex = db.connection().getWriteClient()\n const isAdmin = userId ? await isAdministrator(userId) : false\n return {\n isAdmin: ctx.inertia.always(isAdmin),\n canInviteUsers: ctx.inertia.always(\n userId ? await new UserInvitations(knex).canInvite(userId) : false\n ),\n backupWarning: ctx.inertia.always(isAdmin && (await isBackupStale(knex))),\n unreadNotifications: ctx.inertia.always(\n userId ? await new NotificationsAdmin(knex).unreadCount(userId) : 0\n ),\n impersonating: ctx.inertia.always(Boolean(session?.get(IMPERSONATOR_KEY))),\n }\n}\n\nexport default class AdminMiddleware {\n async handle(ctx: HttpContext, next: NextFn) {\n const user = ctx.auth.getUserOrFail()\n if (!(await isAdministrator(user.id))) {\n if (ctx.request.accepts(['html', 'json']) === 'json')\n return ctx.response.forbidden({\n error: { code: 'E_FORBIDDEN', message: 'هذه المنطقة للمديرين فقط' },\n })\n ctx.response.status(403)\n return ctx.response.send(await ctx.inertia.render('admin/forbidden', {}))\n }\n return next()\n }\n}\n","app/middleware/auth_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport type { Authenticators } from '@adonisjs/auth/types'\nimport { ensureSessionActive } from '#services/sessions'\n\n/**\n * Auth middleware is used authenticate HTTP requests and deny\n * access to unauthenticated users. It also ends sessions that were\n * revoked and sessions of disabled users.\n */\nexport default class AuthMiddleware {\n /**\n * The URL to redirect to, when authentication fails\n */\n redirectTo = '/login'\n\n async handle(\n ctx: HttpContext,\n next: NextFn,\n options: {\n guards?: (keyof Authenticators)[]\n } = {}\n ) {\n await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })\n const state = await ensureSessionActive(ctx)\n if (!state.active) {\n await ctx.auth.use('web').logout()\n const message =\n state.reason === 'disabled'\n ? 'هذا الحساب معطّل. تواصل مع مدير النظام.'\n : 'أُنهيت هذه الجلسة. سجّل الدخول مجدداً.'\n if (ctx.request.accepts(['html', 'json']) === 'json')\n return ctx.response.unauthorized({ errors: [{ message }] })\n ctx.session.flash('error', message)\n return ctx.response.redirect().toPath(this.redirectTo)\n }\n return next()\n }\n}\n","app/middleware/container_bindings_middleware.ts":"import { Logger } from '@adonisjs/core/logger'\nimport { HttpContext } from '@adonisjs/core/http'\nimport { type NextFn } from '@adonisjs/core/types/http'\n\n/**\n * The container bindings middleware binds classes to their request\n * specific value using the container resolver.\n *\n * - We bind \"HttpContext\" class to the \"ctx\" object\n * - And bind \"Logger\" class to the \"ctx.logger\" object\n */\nexport default class ContainerBindingsMiddleware {\n handle(ctx: HttpContext, next: NextFn) {\n ctx.containerResolver.bindValue(HttpContext, ctx)\n ctx.containerResolver.bindValue(Logger, ctx.logger)\n\n return next()\n }\n}\n","app/middleware/guest_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport type { Authenticators } from '@adonisjs/auth/types'\n\n/**\n * Guest middleware is used to deny access to routes that should\n * be accessed by unauthenticated users.\n *\n * For example, the login page should not be accessible if the user\n * is already logged-in\n */\nexport default class GuestMiddleware {\n /**\n * The URL to redirect to when user is logged-in\n */\n redirectTo = '/'\n\n async handle(\n ctx: HttpContext,\n next: NextFn,\n options: { guards?: (keyof Authenticators)[] } = {}\n ) {\n for (let guard of options.guards || [ctx.auth.defaultGuard]) {\n if (await ctx.auth.use(guard).check()) {\n ctx.session.reflash()\n return ctx.response.redirect(this.redirectTo, true)\n }\n }\n\n return next()\n }\n}\n","app/middleware/inertia_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport UserTransformer from '#transformers/user_transformer'\nimport BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware'\nimport { kit } from '#services/kit'\nimport { sharedAdminProps } from '#middleware/admin_middleware'\nimport { uiPreferences } from '@adula/kit'\nimport db from '@adonisjs/lucid/services/db'\n\nexport default class InertiaMiddleware extends BaseInertiaMiddleware {\n async share(ctx: HttpContext) {\n /**\n * The share method is called everytime an Inertia page is rendered. In\n * certain cases, a page may get rendered before the session middleware\n * or the auth middleware are executed. For example: During a 404 request.\n *\n * In that case, we must always assume that HttpContext is not fully hydrated\n * with all the properties\n */\n const { auth } = ctx as Partial<HttpContext>\n\n /**\n * Data shared with all Inertia pages. Make sure you are using\n * transformers for rich data-types like Models.\n */\n return {\n errors: ctx.inertia.always(this.getValidationErrors(ctx)),\n uiPreferences: ctx.inertia.always(await uiPreferences(db.connection().getWriteClient())),\n user: ctx.inertia.always(auth?.user ? UserTransformer.transform(auth.user) : undefined),\n navigation: ctx.inertia.always(\n auth?.user ? kit().resources.navigation(await kit().actors.load(auth.user.id)) : []\n ),\n ...(await sharedAdminProps(ctx)),\n }\n }\n\n /**\n * The flash bag is sent to every Inertia page as a top-level \"flash\" field\n * (a sibling of \"props\") and is read on the client using \"usePage().flash\".\n *\n * Just like the share method, the flash method may run before the session\n * middleware, so HttpContext must be treated as partially hydrated.\n */\n flash(ctx: HttpContext) {\n const { session } = ctx as Partial<HttpContext>\n\n /**\n * Fetching the first error from the flash messages\n */\n return {\n error: session?.flashMessages.get('error') as string | undefined,\n success: session?.flashMessages.get('success') as string | undefined,\n }\n }\n\n async handle(ctx: HttpContext, next: NextFn) {\n await this.init(ctx)\n\n const output = await next()\n this.dispose(ctx)\n\n return output\n }\n}\n\ndeclare module '@adonisjs/inertia/types' {\n type MiddlewareSharedProps = InferSharedProps<InertiaMiddleware>\n export interface SharedProps extends MiddlewareSharedProps {}\n}\n","app/middleware/mcp_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport { isModernProtocolRequest } from '@jrmc/adonis-mcp/protocols/version'\nimport { randomUUID } from 'node:crypto'\n\n// Adapted from @jrmc/adonis-mcp 2.0.0's official middleware stub (MIT).\n// Session authentication and Shield CSRF remain enabled on this route.\nexport default class McpMiddleware {\n async handle(ctx: HttpContext, next: NextFn) {\n const body = ctx.request.body()\n if (ctx.request.header('Content-Type')?.split(';', 1)[0] !== 'application/json')\n return ctx.response.badRequest('Content-Type header must be application/json')\n if (\n isModernProtocolRequest(\n ctx.request.header('MCP-Protocol-Version'),\n body.params?._meta?.['io.modelcontextprotocol/protocolVersion']\n )\n )\n return next()\n if (body.method === 'initialize') ctx.response.safeHeader('MCP-Session-Id', randomUUID())\n else {\n const sessionId = ctx.request.header('MCP-Session-Id')\n if (!sessionId) return ctx.response.badRequest('MCP-Session-Id header is required')\n ctx.response.safeHeader('MCP-Session-Id', sessionId)\n }\n return next()\n }\n}\n","app/middleware/silent_auth_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport { ensureSessionActive } from '#services/sessions'\n\n/**\n * Silent auth middleware can be used as a global middleware to silent check\n * if the user is logged-in or not.\n *\n * The request continues as usual, even when the user is not logged-in. A\n * revoked session or a disabled user is silently signed out.\n */\nexport default class SilentAuthMiddleware {\n async handle(ctx: HttpContext, next: NextFn) {\n await ctx.auth.check()\n if (ctx.auth.isAuthenticated) {\n const state = await ensureSessionActive(ctx)\n if (!state.active) await ctx.auth.use('web').logout()\n }\n\n return next()\n }\n}\n","app/models/user.ts":"import { UserSchema } from '#database/schema'\nimport hash from '@adonisjs/core/services/hash'\nimport { compose } from '@adonisjs/core/helpers'\nimport { withAuthFinder } from '@adonisjs/auth/mixins/lucid'\n\nexport default class User extends compose(UserSchema, withAuthFinder(hash)) {\n get initials() {\n const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')\n if (first && last) {\n return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()\n }\n return `${first.slice(0, 2)}`.toUpperCase()\n }\n}\n","app/services/auth_activity.ts":"import db from '@adonisjs/lucid/services/db'\nimport type { HttpContext } from '@adonisjs/core/http'\n\n/** Authentication events recorded in the kit \"activities\" table (resource \"users\"). */\nexport type AuthAction =\n | 'login'\n | 'login_failed'\n | 'logout'\n | 'password_reset_requested'\n | 'password_reset_delivery_failed'\n | 'password_reset'\n | 'session_revoked'\n | 'oauth_login'\n | 'profile_updated'\n | 'password_changed'\n\nexport type AuthActivityEntry = {\n /** The user the event is about; also the record id of the activity row. */\n userId: number\n /** Defaults to the user (self-service). Administrators pass their own id. */\n actorId?: number\n action: AuthAction\n changes?: Record<string, unknown>\n}\n\ntype Client = ReturnType<ReturnType<typeof db.connection>['getWriteClient']>\n\n/** Request facts stored with every authentication activity. */\nexport function requestContext(ctx: Pick<HttpContext, 'request'>) {\n return {\n ip: ctx.request.ip(),\n userAgent: ctx.request.header('user-agent')?.slice(0, 512) ?? null,\n }\n}\n\n/**\n * Failed logins for unknown e-mails cannot be stored here (actor_id references\n * users); the limiter alone counts those.\n */\nexport async function logAuthActivity(\n entry: AuthActivityEntry,\n client: Client = db.connection().getWriteClient()\n) {\n await client('activities').insert({\n resource: 'users',\n record_id: entry.userId,\n actor_id: entry.actorId ?? entry.userId,\n action: entry.action,\n changes: JSON.stringify(entry.changes ?? {}),\n })\n}\n","app/services/backup_publish.ts":"import { createHash } from 'node:crypto'\nimport { join } from 'node:path'\nimport { fileHash, snapshotFiles } from '#services/backup_snapshot'\n\nexport interface SnapshotTransport {\n exists(): Promise<boolean>\n put(name: string, file?: string): Promise<void>\n read(name: string): Promise<AsyncIterable<Uint8Array>>\n}\n\n/** A remote completion marker is a commit: no success marker on partial/corrupt transfer. */\nexport async function publishSnapshot(directory: string, transport: SnapshotTransport) {\n if (await transport.exists()) throw new Error('Refusing to overwrite a complete offsite snapshot')\n for (const name of [...snapshotFiles, 'SHA256SUMS']) {\n const path = join(directory, name)\n await transport.put(name, path)\n const hash = createHash('sha256')\n for await (const chunk of await transport.read(name)) hash.update(chunk)\n if (hash.digest('hex') !== (await fileHash(path)))\n throw new Error(`Offsite checksum mismatch: ${name}`)\n }\n await transport.put('COMPLETE')\n if (!(await transport.exists())) throw new Error('Offsite completion marker is missing')\n}\n","app/services/backup_snapshot.ts":"import { createHash, randomUUID } from 'node:crypto'\nimport { createReadStream, createWriteStream } from 'node:fs'\nimport { mkdir, mkdtemp, readFile, rm, stat, writeFile, lstat } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\nimport { pipeline } from 'node:stream/promises'\nimport { execFile } from 'node:child_process'\nimport { promisify } from 'node:util'\nimport db from '@adonisjs/lucid/services/db'\nimport drive from '@adonisjs/drive/services/main'\nimport { isRelativeDiskPath } from '@adula/kit'\nimport env from '#start/env'\n\nconst run = promisify(execFile)\nexport const snapshotFiles = ['database.dump', 'uploads.tar.gz', 'attachments.json']\nexport type ArchivedAttachment = {\n id: number\n disk: string\n path: string\n size: number\n sha256: string\n}\nexport const pgEnvironment = () => ({\n ...process.env,\n PGHOST: env.get('DB_HOST'),\n PGPORT: String(env.get('DB_PORT')),\n PGUSER: env.get('DB_USER'),\n PGPASSWORD: env.get('DB_PASSWORD'),\n})\n\nexport async function fileHash(path: string) {\n const hash = createHash('sha256')\n for await (const chunk of createReadStream(path)) hash.update(chunk)\n return hash.digest('hex')\n}\n\nexport function attachmentDisk(name: string) {\n return drive.use(name as 'local' | 's3')\n}\n\n/** The dump and attachment inventory share one PostgreSQL MVCC snapshot. */\nexport async function createSnapshot(directory: string) {\n await mkdir(directory) // Never reuse a partially or fully published snapshot.\n const objects = await mkdtemp(join(tmpdir(), 'adula-backup-'))\n try {\n const entries: ArchivedAttachment[] = []\n await db\n .connection()\n .getWriteClient()\n .transaction(async (trx) => {\n await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY')\n const result = await trx.raw('SELECT pg_export_snapshot() AS snapshot')\n await run(\n 'pg_dump',\n [\n '--format=custom',\n `--snapshot=${result.rows[0].snapshot}`,\n `--file=${join(directory, 'database.dump')}`,\n env.get('DB_DATABASE'),\n ],\n { env: pgEnvironment(), windowsHide: true, timeout: 3600000 }\n )\n // Include released/soft-deleted files too: their rows remain in the dump.\n const rows = await trx('attachments').orderBy('id')\n for (const row of rows) {\n if (!isRelativeDiskPath(row.path)) throw new Error(`Unsafe attachment path: ${row.path}`)\n const file = join(objects, `${row.id}.bin`)\n await pipeline(\n await attachmentDisk(row.disk).getStream(row.path),\n createWriteStream(file)\n )\n const info = await stat(file)\n const size = info.size\n if (size !== Number(row.size)) throw new Error(`Attachment ${row.id} size mismatch`)\n entries.push({\n id: Number(row.id),\n disk: row.disk,\n path: row.path,\n size,\n sha256: await fileHash(file),\n })\n }\n })\n await run('tar', ['-czf', 'uploads.tar.gz', '-C', objects.replaceAll('\\\\', '/'), '.'], {\n cwd: directory,\n windowsHide: true,\n timeout: 3600000,\n })\n await writeFile(\n join(directory, 'attachments.json'),\n JSON.stringify({ version: 1, files: entries }, null, 2) + '\\n'\n )\n const sums = await Promise.all(\n snapshotFiles.map(async (name) => `${await fileHash(join(directory, name))} ${name}`)\n )\n await writeFile(join(directory, 'SHA256SUMS'), sums.join('\\n') + '\\n')\n } finally {\n await rm(objects, { recursive: true, force: true })\n }\n}\n\nexport async function verifySnapshot(directory: string) {\n await stat(join(directory, 'COMPLETE'))\n const text = await readFile(join(directory, 'SHA256SUMS'), 'utf8')\n const names = new Set<string>()\n for (const line of text.trim().split(/\\r?\\n/)) {\n const match = /^([a-f0-9]{64}) {2}(database\\.dump|uploads\\.tar\\.gz|attachments\\.json)$/.exec(\n line\n )\n if (!match || names.has(match[2])) throw new Error('Malformed SHA256SUMS')\n names.add(match[2])\n if ((await fileHash(join(directory, match[2]))) !== match[1])\n throw new Error(`Checksum mismatch for ${match[2]}`)\n }\n if (!names.has('database.dump') || !names.has('uploads.tar.gz'))\n throw new Error('Incomplete SHA256SUMS')\n const hasManifest = await stat(join(directory, 'attachments.json')).then(\n () => true,\n (error) => {\n if (error.code !== 'ENOENT') throw error\n return false\n }\n )\n if (hasManifest !== names.has('attachments.json'))\n throw new Error('Manifest checksum is required')\n if (!hasManifest) return null // Legacy local-only snapshots remain readable.\n const manifest = JSON.parse(await readFile(join(directory, 'attachments.json'), 'utf8'))\n if (manifest.version !== 1 || !Array.isArray(manifest.files))\n throw new Error('Unsupported attachment manifest')\n const ids = new Set<number>()\n for (const file of manifest.files) {\n if (\n !Number.isSafeInteger(file.id) ||\n file.id < 1 ||\n ids.has(file.id) ||\n typeof file.disk !== 'string' ||\n typeof file.path !== 'string' ||\n !isRelativeDiskPath(file.path) ||\n !Number.isSafeInteger(file.size) ||\n file.size < 0 ||\n !/^[a-f0-9]{64}$/.test(file.sha256)\n )\n throw new Error('Invalid attachment manifest entry')\n attachmentDisk(file.disk)\n ids.add(file.id)\n }\n return manifest.files as ArchivedAttachment[]\n}\n\nexport async function extractSnapshot(directory: string, target: string) {\n const archive = join(directory, 'uploads.tar.gz')\n const flags = process.platform === 'win32' ? ['--force-local'] : []\n const options = { windowsHide: true, timeout: 3600000, maxBuffer: 64 * 1024 * 1024 }\n const listing = await run('tar', [...flags, '-tzf', archive], options)\n for (const entry of listing.stdout.trim().split(/\\r?\\n/)) {\n const path = entry.replace(/^\\.\\//, '').replace(/\\/$/, '')\n if (path && path !== '.' && !isRelativeDiskPath(path)) throw new Error('Unsafe archive path')\n }\n // Reject links/devices before extraction; generated snapshots contain regular files only.\n const verbose = await run('tar', [...flags, '-tvzf', archive], options)\n if (verbose.stdout.split(/\\r?\\n/).some((line) => line && !['-', 'd'].includes(line[0])))\n throw new Error('Archive contains a link or special file')\n await run('tar', [...flags, '-xzf', archive], { ...options, cwd: target })\n}\n\nexport async function verifyAttachments(\n files: ArchivedAttachment[],\n extracted: string,\n rows: Record<string, any>[]\n) {\n if (files.length !== rows.length)\n throw new Error('Attachment inventory differs from restored database')\n const inventory = new Map(files.map((file) => [file.id, file]))\n for (const row of rows) {\n const file = inventory.get(Number(row.id))\n if (!file || file.disk !== row.disk || file.path !== row.path || file.size !== Number(row.size))\n throw new Error(`Attachment ${row.id} differs from restored database`)\n const path = join(extracted, `${file.id}.bin`)\n const info = await lstat(path)\n if (!info.isFile() || info.size !== file.size || (await fileHash(path)) !== file.sha256)\n throw new Error(`Attachment ${file.id} checksum mismatch`)\n }\n}\n\n/** Drills write only isolated keys; actual recovery explicitly writes original keys. */\nexport async function restoreAttachments(\n files: ArchivedAttachment[],\n extracted: string,\n drill: boolean\n) {\n const prefix = `.adula-restore-tests/${randomUUID()}`\n for (const file of files) {\n const disk = attachmentDisk(file.disk)\n const key = drill ? `${prefix}/${file.id}.bin` : file.path\n try {\n await disk.putStream(key, createReadStream(join(extracted, `${file.id}.bin`)), {\n contentLength: file.size,\n })\n const hash = createHash('sha256')\n let size = 0\n for await (const chunk of await disk.getStream(key)) {\n hash.update(chunk)\n size += Buffer.byteLength(chunk)\n }\n if (size !== file.size || hash.digest('hex') !== file.sha256)\n throw new Error(`Restored attachment ${file.id} checksum mismatch`)\n } finally {\n if (drill) await disk.delete(key)\n }\n }\n}\n","app/services/events.ts":"import db from '@adonisjs/lucid/services/db'\nimport { AdonisJobs, publishOutbox, type DomainEvent } from '@adula/kit'\nimport DomainEventJob from '../jobs/domain_event_job.js'\n\nexport const jobs = new AdonisJobs({\n 'adula.domain_event': async (data, id) => {\n const event: DomainEvent = {\n id,\n event: String(data.event),\n payload: data.payload as DomainEvent['payload'],\n }\n return await DomainEventJob.dispatch(event).with('jobId', id)\n },\n})\nexport function publishEvents() {\n return publishOutbox(db.connection().getWriteClient(), jobs)\n}\n","app/services/initial_setup.ts":"import { createHmac, randomUUID } from 'node:crypto'\nimport { readFile } from 'node:fs/promises'\nimport { Readable } from 'node:stream'\nimport app from '@adonisjs/core/services/app'\nimport db from '@adonisjs/lucid/services/db'\nimport redis from '@adonisjs/redis/services/main'\nimport drive from '@adonisjs/drive/services/main'\nimport { InitialSetup, Settings, runtimeHealth, type SetupCheck } from '@adula/kit'\nimport env from '#start/env'\nimport { mailTest, mailFingerprint, publicMailTest } from '#services/mail_delivery_test'\n\nconst database = () => db.connection().getWriteClient()\nexport const setup = () => new InitialSetup(database())\nexport const backupFingerprint = () =>\n fingerprint([\n env.get('BACKUP_S3_ENDPOINT'),\n env.get('BACKUP_S3_BUCKET'),\n env.get('BACKUP_S3_PREFIX') ?? 'adula',\n env.get('BACKUP_S3_REGION'),\n env.get('BACKUP_S3_ACCESS_KEY_ID'),\n env.get('BACKUP_S3_SECRET_ACCESS_KEY'),\n ])\nexport const fingerprint = (values: unknown[]) =>\n createHmac('sha256', env.get('APP_KEY').release()).update(JSON.stringify(values)).digest('hex')\nexport const storageFingerprint = () =>\n fingerprint([\n env.get('DRIVE_DISK'),\n env.get('AWS_ENDPOINT'),\n env.get('S3_BUCKET'),\n env.get('AWS_REGION'),\n env.get('AWS_ACCESS_KEY_ID'),\n env.get('AWS_SECRET_ACCESS_KEY'),\n ])\nexport const infrastructureFingerprint = () =>\n fingerprint([\n env.get('DB_HOST'),\n env.get('DB_PORT'),\n env.get('DB_DATABASE'),\n env.get('DB_USER'),\n env.get('DB_PASSWORD'),\n env.get('REDIS_HOST'),\n env.get('REDIS_PORT'),\n env.get('REDIS_PASSWORD'),\n ])\nexport const oauthFingerprint = (provider: 'github' | 'google') =>\n fingerprint(\n provider === 'github'\n ? [env.get('GITHUB_CLIENT_ID'), env.get('GITHUB_CLIENT_SECRET'), env.get('APP_URL')]\n : [env.get('GOOGLE_CLIENT_ID'), env.get('GOOGLE_CLIENT_SECRET'), env.get('APP_URL')]\n )\n\nexport async function identity() {\n try {\n const brand = JSON.parse(await readFile(app.makePath('company-identity.json'), 'utf8')) as {\n company: string\n logo?: string\n }\n return { company: String(brand.company), logo: brand.logo ?? null }\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n const root = await database()('org_units').whereNull('parent_id').orderBy('id').first('name')\n return { company: root?.name ?? 'التطبيق المرجعي', logo: null }\n }\n}\n\nexport async function probeStorage() {\n const disk = drive.use()\n const path = `.adula-setup/${randomUUID()}.txt`\n const payload = Buffer.from(`adula-storage-check:${randomUUID()}`)\n try {\n await disk.putStream(path, Readable.from(payload), { contentLength: payload.length })\n const chunks: Buffer[] = []\n for await (const chunk of await disk.getStream(path)) chunks.push(Buffer.from(chunk))\n if (!Buffer.concat(chunks).equals(payload)) throw new Error('Storage round trip mismatch')\n } finally {\n await disk.delete(path)\n }\n}\nexport async function identityFingerprint() {\n const sources = await Promise.all(\n ['docs/design-identity.md', 'inertia/css/brand.css', 'inertia/brand.ts'].map(async (path) => {\n try {\n return await readFile(app.makePath(path), 'utf8')\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''\n throw error\n }\n })\n )\n return fingerprint([await identity(), ...sources])\n}\nexport async function probeInfrastructure() {\n await database().raw('SELECT 1')\n if ((await redis.ping()) !== 'PONG') throw new Error('Redis unavailable')\n}\n\nexport async function setupSnapshot(user: { id: number; email: string }) {\n const settings = new Settings(database())\n const brand = await identity()\n const approved = await settings.get<{ fingerprint: string }>('setup.identity')\n const health = await runtimeHealth(database())\n const currentCheck = async (name: string, key: string) => {\n const state = await settings.get<SetupCheck>(`setup.check.${name}`)\n if (!state || state.fingerprint !== key) return null\n return {\n status: state.status,\n checkedAt: state.checkedAt,\n fresh: Date.now() - Date.parse(state.checkedAt) < 86_400_000,\n }\n }\n const notification = await settings.get<{ id: number; at: string }>(\n 'setup.notification',\n 'user',\n String(user.id)\n )\n const receipt = notification\n ? await database()('notifications')\n .where({ id: notification.id, user_id: user.id })\n .first('read_at')\n : null\n const mail = await mailTest().current(user.id, user.email, mailFingerprint())\n const oauth = await Promise.all(\n (['github', 'google'] as const).map(async (provider) => {\n const configured =\n provider === 'github'\n ? Boolean(env.get('GITHUB_CLIENT_ID') && env.get('GITHUB_CLIENT_SECRET'))\n : Boolean(env.get('GOOGLE_CLIENT_ID') && env.get('GOOGLE_CLIENT_SECRET'))\n const proof = await settings.get<{ fingerprint: string; at: string }>(\n `setup.oauth.${provider}`\n )\n return {\n provider,\n configured,\n verifiedAt:\n configured && proof?.fingerprint === oauthFingerprint(provider) ? proof.at : null,\n }\n })\n )\n const storage = await currentCheck('storage', storageFingerprint())\n const infrastructure = await currentCheck('infrastructure', infrastructureFingerprint())\n const backupConfigured = [\n 'BACKUP_S3_ENDPOINT',\n 'BACKUP_S3_BUCKET',\n 'BACKUP_S3_REGION',\n 'BACKUP_S3_ACCESS_KEY_ID',\n 'BACKUP_S3_SECRET_ACCESS_KEY',\n ].every((key) => Boolean(env.get(key as 'BACKUP_S3_BUCKET')))\n const backupHealth = await settings.get<{ healthy: boolean; checkedAt: string }>('backup.health')\n const backupProof = await settings.get<{ fingerprint: string }>('setup.backup_check')\n const restore = await settings.get<{\n status?: 'passed' | 'failed'\n finishedAt?: string\n error?: string\n attachment?: { fileVerified?: boolean }\n }>('backup.lastRestoreTestReport')\n return {\n brand,\n identityConfirmed: approved?.fingerprint === (await identityFingerprint()),\n mailTest: publicMailTest(mail),\n mailRecipient: user.email,\n notification: notification ? { ...notification, read: Boolean(receipt?.read_at) } : null,\n storage,\n storageDisk: env.get('DRIVE_DISK'),\n infrastructure,\n health,\n oauth,\n backup: {\n configured: backupConfigured,\n inspection: backupProof?.fingerprint === backupFingerprint() ? (backupHealth ?? null) : null,\n restore: restore\n ? {\n status: restore.status,\n finishedAt: restore.finishedAt,\n fileVerified: Boolean(restore.attachment?.fileVerified),\n }\n : null,\n },\n environment: app.inProduction ? 'production' : 'development',\n }\n}\n","app/services/kit.ts":"import db from '@adonisjs/lucid/services/db'\nimport { ActorStore, ResourceService, SavedViews } from '@adula/kit'\nimport { registry } from '#start/modules'\nimport cache from '@adonisjs/cache/services/main'\nimport type { Actor } from '@adula/kit'\n\nexport function kit() {\n const knex = db.connection().getWriteClient()\n return {\n registry,\n resources: new ResourceService(knex, registry),\n savedViews: new SavedViews(knex, registry),\n actors: new ActorStore(knex, registry, {\n get: async (key) => (await cache.get<Actor>({ key })) ?? undefined,\n set: async (key, value) => {\n await cache.set({ key, value, ttl: '5m' })\n },\n }),\n }\n}\n","app/services/mail_delivery_test.ts":"import { createHmac } from 'node:crypto'\nimport mail from '@adonisjs/mail/services/main'\nimport { MailDeliveryTest, type MailTestState } from '@adula/kit'\nimport db from '@adonisjs/lucid/services/db'\nimport env from '#start/env'\n\nexport const mailTest = () => new MailDeliveryTest(db.connection().getWriteClient())\nexport function publicMailTest(\n state: MailTestState | null\n): Omit<MailTestState, 'fingerprint'> | null {\n if (!state) return null\n return {\n id: state.id,\n recipient: state.recipient,\n status: state.status,\n requestedAt: state.requestedAt,\n answeredAt: state.answeredAt,\n }\n}\n// Configuration changes invalidate old attestations; secrets never reach page props.\nexport function mailFingerprint() {\n return createHmac('sha256', env.get('APP_KEY').release())\n .update(\n JSON.stringify([\n env.get('SMTP_HOST'),\n env.get('SMTP_PORT'),\n env.get('SMTP_SECURE'),\n env.get('SMTP_REQUIRE_TLS'),\n env.get('SMTP_USERNAME'),\n env.get('SMTP_PASSWORD'),\n env.get('MAIL_FROM_ADDRESS'),\n env.get('MAIL_FROM_NAME'),\n env.get('MAIL_MAILER'),\n ])\n )\n .digest('hex')\n}\nexport async function sendMailTest(recipient: string, id: string) {\n await mail.send((message) => {\n message\n .to(recipient)\n .subject('تجربة البريد — تأكيد الاستلام')\n .text(\n `هذه رسالة تجريبية طلبتها من إعدادات النظام.\\nمرجع التجربة: ${id}\\nارجع إلى إعدادات البريد واختر «وصلت الرسالة» لتأكيد استلام هذه التجربة.\\nقبول خادم البريد للإرسال لا يعني تأكيد وصولها.`\n )\n })\n}\n","app/services/mcp.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { createResourceTools } from '@adula/kit/mcp'\nimport { KitError } from '@adula/kit'\nimport { kit } from '#services/kit'\n\ndeclare module '@jrmc/adonis-mcp/types/context' {\n interface McpContext {\n auth?: HttpContext['auth']\n }\n}\n\nexport const resourceTools = createResourceTools(async (context) => {\n const user = context.auth?.user\n if (!user) throw new KitError(401, 'E_UNAUTHORIZED', 'سجل الدخول أولاً')\n const runtime = kit()\n return { ...runtime, actor: await runtime.actors.load(user.id) }\n})\n","app/services/sessions.ts":"import db from '@adonisjs/lucid/services/db'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\n\n/** A signed-in browser session as shown to users and administrators. */\nexport type UserSession = {\n id: string\n userId: number\n ip: string | null\n userAgent: string | null\n createdAt: string\n lastSeenAt: string\n revokedAt: string | null\n}\nexport type ActiveSession = UserSession & { email: string; fullName: string | null }\n\n/** Presence is refreshed at most once per window to keep reads cheap. */\nconst PRESENCE_WINDOW_MS = 5 * 60 * 1000\n\nconst knex = () => db.connection().getWriteClient()\nconst iso = (value: unknown) => (value instanceof Date ? value.toISOString() : String(value))\nfunction toSession(row: Record<string, unknown>): UserSession {\n return {\n id: String(row.id),\n userId: Number(row.user_id),\n ip: (row.ip as string | null) ?? null,\n userAgent: (row.user_agent as string | null) ?? null,\n createdAt: iso(row.created_at),\n lastSeenAt: iso(row.last_seen_at),\n revokedAt: row.revoked_at ? iso(row.revoked_at) : null,\n }\n}\n\n/** Records the current session for a user right after login, signup or OAuth login. */\nexport async function recordSession(ctx: HttpContext, userId: number) {\n const { ip, userAgent } = requestContext(ctx)\n await knex()('user_sessions')\n .insert({ id: ctx.session.sessionId, user_id: userId, ip, user_agent: userAgent })\n .onConflict('id')\n .merge({ user_id: userId, ip, user_agent: userAgent, last_seen_at: knex().fn.now() })\n}\n\n/** A voluntary logout closes the row without a \"session_revoked\" activity. */\nexport async function endSession(sessionId: string) {\n await knex()('user_sessions')\n .where({ id: sessionId })\n .whereNull('revoked_at')\n .update({ revoked_at: knex().fn.now() })\n}\n\nexport type SessionState = { active: true } | { active: false; reason: 'disabled' | 'revoked' }\n\n/** The silent and strict auth middleware both check; one query per request is enough. */\nconst checked = new WeakMap<HttpContext, Promise<SessionState>>()\n\n/**\n * Verifies that the authenticated request still owns a live session: the user\n * is not disabled and the session row was not revoked. Sessions created outside\n * the login flow (for example by the test client) are recorded lazily, and the\n * presence timestamp is refreshed at most every five minutes.\n */\nexport function ensureSessionActive(ctx: HttpContext): Promise<SessionState> {\n let pending = checked.get(ctx)\n if (!pending) {\n pending = checkSession(ctx)\n checked.set(ctx, pending)\n }\n return pending\n}\n\nasync function checkSession(ctx: HttpContext): Promise<SessionState> {\n const user = ctx.auth.user\n if (!user) return { active: true }\n if (user.disabledAt) return { active: false, reason: 'disabled' }\n const sessionId = ctx.session.sessionId\n const row = await knex()('user_sessions').where({ id: sessionId }).first()\n if (!row) {\n const { ip, userAgent } = requestContext(ctx)\n await knex()('user_sessions')\n .insert({ id: sessionId, user_id: user.id, ip, user_agent: userAgent })\n .onConflict('id')\n .ignore()\n return { active: true }\n }\n if (row.revoked_at) return { active: false, reason: 'revoked' }\n const stale = Date.now() - new Date(row.last_seen_at).getTime() > PRESENCE_WINDOW_MS\n if (stale || row.user_id !== user.id) {\n const { ip, userAgent } = requestContext(ctx)\n await knex()('user_sessions')\n .where({ id: sessionId })\n .update({ user_id: user.id, ip, user_agent: userAgent, last_seen_at: knex().fn.now() })\n }\n return { active: true }\n}\n\n/** Live sessions of one user, most recently seen first. */\nexport async function listUserSessions(userId: number): Promise<UserSession[]> {\n const rows = await knex()('user_sessions')\n .where({ user_id: userId })\n .whereNull('revoked_at')\n .orderBy('last_seen_at', 'desc')\n return rows.map(toSession)\n}\n\n/** Every live session across users, for the administrative screen. */\nexport async function listActiveSessions(): Promise<ActiveSession[]> {\n const rows = await knex()('user_sessions')\n .join('users', 'users.id', 'user_sessions.user_id')\n .whereNull('user_sessions.revoked_at')\n .orderBy('user_sessions.last_seen_at', 'desc')\n .select('user_sessions.*', 'users.email', 'users.full_name')\n return rows.map((row) => ({\n ...toSession(row),\n email: String(row.email),\n fullName: (row.full_name as string | null) ?? null,\n }))\n}\n\n/**\n * Revokes one session: marks the row, deletes the session-store row so the\n * guard drops it on the next request, and records the activity for the owner.\n * Returns null when the session is unknown or already revoked.\n */\nexport async function revokeSession(sessionId: string, actorId: number) {\n return await knex().transaction(async (trx) => {\n const [row] = await trx('user_sessions')\n .where({ id: sessionId })\n .whereNull('revoked_at')\n .update({ revoked_at: trx.fn.now() })\n .returning('*')\n if (!row) return null\n await trx('sessions').where({ id: sessionId }).delete()\n await logAuthActivity(\n {\n userId: row.user_id,\n actorId,\n action: 'session_revoked',\n changes: { sessionIds: [sessionId], ip: row.ip, userAgent: row.user_agent },\n },\n trx\n )\n return toSession(row)\n })\n}\n\n/** Revokes every live session of a user, optionally keeping the current one. */\nexport async function revokeUserSessions(\n userId: number,\n actorId: number,\n options: { except?: string } = {}\n) {\n return await knex().transaction(async (trx) => {\n const query = trx('user_sessions').where({ user_id: userId }).whereNull('revoked_at')\n if (options.except) query.whereNot({ id: options.except })\n const rows = await query.update({ revoked_at: trx.fn.now() }).returning('*')\n if (!rows.length) return 0\n const ids = rows.map((row) => String(row.id))\n await trx('sessions').whereIn('id', ids).delete()\n await logAuthActivity(\n { userId, actorId, action: 'session_revoked', changes: { sessionIds: ids } },\n trx\n )\n return rows.length\n })\n}\n","app/services/social_accounts.ts":"import env from '#start/env'\nimport User from '#models/user'\nimport db from '@adonisjs/lucid/services/db'\nimport { randomBytes } from 'node:crypto'\nimport type { SocialProviders } from '@adonisjs/ally/types'\n\nexport type SocialProvider = keyof SocialProviders\nexport type SocialProviderOption = { name: SocialProvider; label: string }\n\n/**\n * A provider is offered only when both of its credentials exist. The list is\n * computed once at boot and shared with the login page as \"socialProviders\".\n */\nconst catalogue: (SocialProviderOption & { id: string | undefined; secret: string | undefined })[] =\n [\n {\n name: 'github',\n label: 'GitHub',\n id: env.get('GITHUB_CLIENT_ID'),\n secret: env.get('GITHUB_CLIENT_SECRET'),\n },\n {\n name: 'google',\n label: 'Google',\n id: env.get('GOOGLE_CLIENT_ID'),\n secret: env.get('GOOGLE_CLIENT_SECRET'),\n },\n ]\nconst configured: SocialProviderOption[] = catalogue\n .filter((provider) => provider.id && provider.secret)\n .map(({ name, label }) => ({ name, label }))\n\nexport function socialProviders(): SocialProviderOption[] {\n return configured\n}\n\nexport function isSocialProvider(value: unknown): value is SocialProvider {\n return configured.some((provider) => provider.name === value)\n}\n\nexport type SocialProfile = {\n provider: SocialProvider\n providerId: string\n /** Verified by the provider; callers must refuse unverified addresses first. */\n email: string\n name: string | null\n}\n\n/**\n * Resolves the local user for an OAuth identity: an existing link wins, then a\n * user with the same e-mail is linked, otherwise a user is created with an\n * unguessable password. Runs in one transaction against PostgreSQL.\n */\nexport async function linkOrCreateSocialUser(profile: SocialProfile) {\n return await db.transaction(async (trx) => {\n const link = await trx\n .from('social_accounts')\n .where({ provider: profile.provider, provider_id: profile.providerId })\n .first()\n if (link) {\n const linked = await User.query({ client: trx }).where('id', link.user_id).firstOrFail()\n return { user: linked, created: false, linked: false }\n }\n let user = await User.query({ client: trx })\n .whereRaw('lower(email) = lower(?)', [profile.email])\n .first()\n let created = false\n if (!user) {\n user = await User.create(\n {\n email: profile.email,\n fullName: profile.name,\n password: randomBytes(32).toString('base64url'),\n },\n { client: trx }\n )\n created = true\n }\n await trx\n .table('social_accounts')\n .insert({ provider: profile.provider, provider_id: profile.providerId, user_id: user.id })\n return { user, created, linked: !created }\n })\n}\n","app/transformers/user_transformer.ts":"import type User from '#models/user'\nimport { BaseTransformer } from '@adonisjs/core/transformers'\n\nexport default class UserTransformer extends BaseTransformer<User> {\n toObject() {\n return this.pick(this.resource, [\n 'id',\n 'fullName',\n 'email',\n 'createdAt',\n 'updatedAt',\n 'initials',\n ])\n }\n}\n","app/validators/user.ts":"import vine, { SimpleMessagesProvider } from '@vinejs/vine'\n\n/**\n * Shared rules for email and password.\n */\nconst email = () => vine.string().email().maxLength(254)\nconst password = () => vine.string().minLength(8).maxLength(32)\nexport const invitationValidator = vine.create({\n fullName: vine.string().trim().minLength(1).maxLength(120),\n email: vine.string().trim().email().maxLength(254),\n})\nconst invitationMessages = new SimpleMessagesProvider({\n required: 'هذا الحقل مطلوب',\n string: 'أدخل قيمة نصية صحيحة',\n email: 'أدخل بريدًا إلكترونيًا صحيحًا',\n minLength: 'عدد الأحرف أقل من الحد المطلوب ({{ min }})',\n maxLength: 'عدد الأحرف يتجاوز الحد المسموح ({{ max }})',\n confirmed: 'يجب أن تتطابق كلمتا المرور',\n})\ninvitationValidator.messagesProvider = invitationMessages\n\nexport const acceptInvitationValidator = vine.create({\n password: password().confirmed({ confirmationField: 'passwordConfirmation' }),\n passwordConfirmation: vine.string(),\n})\nacceptInvitationValidator.messagesProvider = invitationMessages\n\n/**\n * Validator to use when performing self-signup.\n *\n * The \"passwordConfirmation\" field is declared explicitly, so that it is part\n * of the request body type shared with the frontend. Otherwise the signup form\n * has no way to know about the errors reported for this field.\n */\nexport const signupValidator = vine.create({\n fullName: vine.string().nullable(),\n email: email().unique({ table: 'users', column: 'email' }),\n password: password().confirmed({\n confirmationField: 'passwordConfirmation',\n }),\n passwordConfirmation: vine.string(),\n})\n\n/**\n * Validator to use when logging in an existing user\n */\nexport const loginValidator = vine.create({\n email: email(),\n password: vine.string(),\n})\n\n/**\n * Validator for requesting a password-recovery e-mail.\n */\nexport const forgotPasswordValidator = vine.create({\n email: email(),\n})\n\n/**\n * Validator for choosing a new password from a recovery link.\n */\nexport const resetPasswordValidator = vine.create({\n password: password().confirmed({\n confirmationField: 'passwordConfirmation',\n }),\n passwordConfirmation: vine.string(),\n})\n\n/**\n * Validator for the self-service profile form.\n */\nexport const profileValidator = vine.create({\n fullName: vine.string().trim().minLength(2).maxLength(120),\n})\n\n/**\n * Validator for changing the password of the signed-in user. The current\n * password is verified by the controller against the stored hash.\n */\nexport const changePasswordValidator = vine.create({\n currentPassword: vine.string(),\n password: password().confirmed({\n confirmationField: 'passwordConfirmation',\n }),\n passwordConfirmation: vine.string(),\n})\n","config/ally.ts":"import env from '#start/env'\nimport { defineConfig, services } from '@adonisjs/ally'\n\n/**\n * Providers are always declared so their types exist; a provider is offered\n * to users only when its credentials are configured (see start/routes.ts).\n */\nconst allyConfig = defineConfig({\n github: services.github({\n clientId: env.get('GITHUB_CLIENT_ID') ?? '',\n clientSecret: env.get('GITHUB_CLIENT_SECRET') ?? '',\n callbackUrl: `${env.get('APP_URL')}/oauth/github/callback`,\n }),\n google: services.google({\n clientId: env.get('GOOGLE_CLIENT_ID') ?? '',\n clientSecret: env.get('GOOGLE_CLIENT_SECRET') ?? '',\n callbackUrl: `${env.get('APP_URL')}/oauth/google/callback`,\n }),\n})\n\nexport default allyConfig\n\ndeclare module '@adonisjs/ally/types' {\n interface SocialProviders extends InferSocialProviders<typeof allyConfig> {}\n}\n","config/app.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig } from '@adonisjs/core/http'\n\n/**\n * The app URL can be used in various places where you want to create absolute\n * URLs to your application. For example, when sending emails, images should\n * use absolute URLs.\n */\nexport const appUrl = env.get('APP_URL')\n\n/**\n * The configuration settings used by the HTTP server\n */\nexport const http = defineConfig({\n /**\n * Generate a unique request id for each incoming request.\n * Useful to correlate logs and debug a request flow.\n */\n generateRequestId: true,\n\n /**\n * Allow HTTP method spoofing via the \"_method\" form/query parameter.\n * This lets HTML forms target PUT/PATCH/DELETE routes while still\n * submitting with POST.\n */\n allowMethodSpoofing: false,\n\n /**\n * Enabling async local storage will let you access HTTP context\n * from anywhere inside your application.\n */\n useAsyncLocalStorage: false,\n\n /**\n * Redirect configuration controls the behavior of\n * response.redirect().back() and query string forwarding.\n */\n redirect: {\n /**\n * When enabled, all redirects automatically carry over the current\n * request's query string parameters to the redirect destination.\n * Use withQs(false) to opt out for a specific redirect.\n */\n forwardQueryString: true,\n },\n\n /**\n * Manage cookies configuration. The settings for the session id cookie are\n * defined inside the \"config/session.ts\" file.\n */\n cookie: {\n /**\n * Restrict the cookie to a specific domain.\n * Keep empty to use the current host.\n */\n domain: '',\n\n /**\n * Restrict the cookie to a URL path. '/' means all routes.\n */\n path: '/',\n\n /**\n * Default lifetime for cookies managed by the HTTP layer.\n */\n maxAge: '2h',\n\n /**\n * Prevent JavaScript access to the cookie in the browser.\n */\n httpOnly: true,\n\n /**\n * Send cookies only over HTTPS in production.\n */\n secure: app.inProduction,\n\n /**\n * Cross-site policy for cookie sending.\n */\n sameSite: 'lax',\n },\n})\n","config/attachment.ts":"import type { InferConverters } from '@jrmc/adonis-attachment/types/config'\nimport { defineConfig } from '@jrmc/adonis-attachment'\n// import sharp from 'sharp'\n\n/**\n * Documentation: https://adonis-attachment.jrmc.dev/guide/essentials/configuration\n */\n\nconst attachmentConfig = defineConfig({\n /**\n * Enable the preComputeUrl flag to pre compute the URLs after SELECT queries. (default: false)\n */\n // preComputeUrl: true,\n\n /**\n * Enable the meta informations after upload. (default: false)\n */\n // meta: true,\n\n /**\n * Enable file rename after upload. (default: true)\n */\n // rename: false,\n\n /**\n * Specify binary path\n */\n // bin: { // [!code focus:8]\n // ffmpegPath: 'ffmpeg_path', // the full path of the binary\n // ffprobePath: 'ffprobe_path', // the full path of the binary\n // pdftoppmPath: 'pdftoppm_path' // the full path of the binary\n // pdfinfoPath: 'pdfinfo_path' // the full path of the binary\n // sofficePath: 'soffice_path', // the full path of the binary (libreoffice/openoffice)\n // },\n\n /**\n * Queue configuration for file processing.\n * By default, 1 task is processed concurrently. A task corresponds to a model attribute.\n * For example, if a model has a logo attribute and an avatar attribute,\n * this represents 2 tasks, regardless of the number of concerts per attribute.\n *\n * Increasing concurrency can improve performance but consumes more resources.\n * A value too high may lead to memory or CPU issues.\n *\n */\n // queue: {\n // concurrency: 2\n // },\n\n /**\n * Maximum duration (in milliseconds) that an operation can take before being interrupted.\n * Default: 30_000 (30 seconds)\n *\n * This timeout applies to each individual operation conversion.\n * If an operation exceeds this time limit, it will be interrupted and an error will be thrown.\n *\n * Increase this value if you're processing large files or if your operations\n * require more time (e.g., long video conversion).\n *\n */\n // timeout: 40_000,\n\n /**\n * Configure how variants are stored relative to the original file.\n *\n * - 'basePath': Define a custom base path where all variants will be stored.\n * By default, variants are stored in the same folder as the original file.\n *\n * - 'ignoreFolder': When set to 'true', the variant will not include the parent\n * folder from the original attachment.\n */\n // variant: {\n // basePath: 'variants',\n // ignoreFolder: true,\n // },\n\n /**\n *\n */\n converters: {\n thumbnail: {\n /**\n * optional converter\n * default : @jrmc/adonis-attachment/converters/autodetect_converter\n * image : @jrmc/adonis-attachment/converters/image_converter\n * pdf : @jrmc/adonis-attachment/converters/pdf_thumbnail_converter\n * document : @jrmc/adonis-attachment/converters/document_thumbnail_converter\n * video : @jrmc/adonis-attachment/converters/video_thumbnail_converter\n * create your custom converter : https://adonis-attachment.jrmc.dev/guide/advanced_usage/custom-converter\n */\n // converter: () => import('@jrmc/adonis-attachment/converters/autodetect_converter'),\n\n /**\n *\n * https://sharp.pixelplumbing.com/api-resize/\n */\n resize: 300,\n\n // resize: { // https://sharp.pixelplumbing.com/api-resize\n // width: 400,\n // height: 400,\n // fit: sharp.fit.cover,\n // position: 'top'\n // },\n\n /**\n *\n * https://sharp.pixelplumbing.com/api-output/#toformat\n */\n // format: 'jpeg',\n // format: {\n // format: 'jpeg',\n // options: {\n // quality: 80\n // }\n // }\n\n /**\n *\n * https://sharp.pixelplumbing.com/api-operation/#autoorient\n */\n // autoOrient: false,\n\n /**\n * generation of blurhashes (default: true)\n * https://blurha.sh/\n */\n // blurhash: true,\n },\n },\n})\n\nexport default attachmentConfig\n\ndeclare module '@jrmc/adonis-attachment' {\n interface AttachmentVariants extends InferConverters<typeof attachmentConfig> {}\n}\n","config/auth.ts":"import { defineConfig } from '@adonisjs/auth'\nimport { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'\nimport type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'\n\nconst authConfig = defineConfig({\n /**\n * Default guard used when no guard is explicitly specified.\n */\n default: 'web',\n\n guards: {\n /**\n * Session-based guard for browser authentication.\n */\n web: sessionGuard({\n /**\n * Enable persistent login using remember-me tokens.\n */\n useRememberMeTokens: false,\n\n provider: sessionUserProvider({\n model: () => import('#models/user'),\n }),\n }),\n },\n})\n\nexport default authConfig\n\n/**\n * Inferring types from the configured auth\n * guards.\n */\ndeclare module '@adonisjs/auth/types' {\n export interface Authenticators extends InferAuthenticators<typeof authConfig> {}\n}\ndeclare module '@adonisjs/core/types' {\n interface EventsList extends InferAuthEvents<Authenticators> {}\n}\n","config/bodyparser.ts":"import { defineConfig } from '@adonisjs/core/bodyparser'\n\nconst bodyParserConfig = defineConfig({\n /**\n * Parse request bodies for these HTTP methods.\n * Keep this aligned with methods that receive payloads in your routes.\n */\n allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],\n\n /**\n * Config for the \"application/x-www-form-urlencoded\"\n * content-type parser.\n */\n form: {\n /**\n * Normalize empty string values to null.\n */\n convertEmptyStringsToNull: true,\n\n /**\n * Content types handled by the form parser.\n */\n types: ['application/x-www-form-urlencoded'],\n },\n\n /**\n * Config for the JSON parser.\n */\n json: {\n /**\n * Normalize empty string values to null.\n */\n convertEmptyStringsToNull: true,\n\n /**\n * Content types handled by the JSON parser.\n */\n types: [\n 'application/json',\n 'application/json-patch+json',\n 'application/vnd.api+json',\n 'application/csp-report',\n ],\n },\n\n /**\n * Config for the \"multipart/form-data\" content-type parser.\n * File uploads are handled by the multipart parser.\n */\n multipart: {\n /**\n * Automatically process uploaded files into the system tmp directory.\n */\n autoProcess: true,\n\n /**\n * Normalize empty string values to null.\n */\n convertEmptyStringsToNull: true,\n\n /**\n * Routes where multipart processing is handled manually.\n */\n processManually: [],\n\n /**\n * Maximum accepted payload size for multipart requests.\n */\n limit: '20mb',\n\n /**\n * Content types handled by the multipart parser.\n */\n types: ['multipart/form-data'],\n },\n})\n\nexport default bodyParserConfig\n","config/cache.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, store, drivers } from '@adonisjs/cache'\nimport type { InferStores } from '@adonisjs/cache/types'\n\nconst cacheConfig = defineConfig({\n default: 'redis',\n prefix: app.inTest ? `${env.get('ADULA_NAMESPACE')}-test` : env.get('ADULA_NAMESPACE'),\n ttl: '5m',\n stores: {\n redis: store()\n .useL1Layer(drivers.memory())\n .useL2Layer(drivers.redis({ connectionName: 'main' })),\n },\n})\nexport default cacheConfig\ndeclare module '@adonisjs/cache/types' {\n interface CacheStores extends InferStores<typeof cacheConfig> {}\n}\n","config/cors.ts":"import app from '@adonisjs/core/services/app'\nimport { defineConfig } from '@adonisjs/cors'\n\n/**\n * Configuration options to tweak the CORS policy. The following\n * options are documented on the official documentation website.\n *\n * https://docs.adonisjs.com/guides/security/cors\n */\nconst corsConfig = defineConfig({\n /**\n * Enable or disable CORS handling globally.\n */\n enabled: true,\n\n /**\n * In development, allow every origin to simplify local front/backend setup.\n * In production, keep an explicit allowlist (empty by default, so no\n * cross-origin browser access is allowed until configured).\n */\n origin: app.inDev ? true : [],\n\n /**\n * HTTP methods accepted for cross-origin requests.\n */\n methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],\n\n /**\n * Reflect request headers by default. Use a string array to restrict\n * allowed headers.\n */\n headers: true,\n\n /**\n * Response headers exposed to the browser.\n */\n exposeHeaders: [],\n\n /**\n * Allow cookies/authorization headers on cross-origin requests.\n */\n credentials: true,\n\n /**\n * Cache CORS preflight response for N seconds.\n */\n maxAge: 90,\n})\n\nexport default corsConfig\n","config/database.ts":"import env from '#start/env'\nimport { defineConfig } from '@adonisjs/lucid'\nimport { modules } from '#start/modules'\n\nexport default defineConfig({\n connection: 'postgres',\n connections: {\n postgres: {\n client: 'pg',\n connection: {\n host: env.get('DB_HOST'),\n port: env.get('DB_PORT'),\n user: env.get('DB_USER'),\n password: env.get('DB_PASSWORD'),\n database: env.get('DB_DATABASE'),\n },\n pool: { min: 0, max: 10 },\n migrations: {\n naturalSort: true,\n paths: [\n 'database/migrations',\n 'node_modules/@adula/kit/build/database/migrations',\n ...modules.map(\n (module) =>\n `app/modules/${module.name}/migrations`\n ),\n ],\n },\n },\n },\n})\n","config/drive.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, services } from '@adonisjs/drive'\n\n/**\n * Files are private on every disk: downloads are served only through the\n * authorized attachment route, never by the disk itself. Paths stored in the\n * database are relative to the disk root so `adula:storage:migrate` can move\n * the files between disks without rewriting records.\n */\nconst driveConfig = defineConfig({\n default: env.get('DRIVE_DISK'),\n services: {\n local: services.fs({\n location: app.makePath('storage/uploads'),\n serveFiles: false,\n visibility: 'private',\n }),\n s3: services.s3({\n credentials: {\n accessKeyId: env.get('AWS_ACCESS_KEY_ID') ?? '',\n secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY') ?? '',\n },\n region: env.get('AWS_REGION') ?? 'auto',\n endpoint: env.get('AWS_ENDPOINT'),\n bucket: env.get('S3_BUCKET') ?? '',\n visibility: 'private',\n // Bucket policies and IAM control access when Object Ownership disables ACLs.\n supportsACL: false,\n }),\n // Test-only second filesystem disk: `adula:storage:migrate` is verified against a real move.\n ...(app.inTest\n ? {\n local_test_archive: services.fs({\n location: app.makePath('storage/uploads-test-archive'),\n serveFiles: false,\n visibility: 'private',\n }),\n }\n : {}),\n },\n})\n\nexport default driveConfig\n\ndeclare module '@adonisjs/drive/types' {\n export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}\n}\n","config/encryption.ts":"import env from '#start/env'\nimport { defineConfig, drivers } from '@adonisjs/core/encryption'\n\nconst encryptionConfig = defineConfig({\n /**\n * Default encryption driver used by the application.\n */\n default: 'gcm',\n\n list: {\n gcm: drivers.aes256gcm({\n /**\n * Keys used for encryption/decryption.\n * First key encrypts, all keys are tried for decryption.\n */\n keys: [env.get('APP_KEY')],\n\n /**\n * Stable identifier for this driver.\n */\n id: 'gcm',\n }),\n },\n})\n\nexport default encryptionConfig\n\n/**\n * Inferring types for the list of encryptors you have configured\n * in your application.\n */\ndeclare module '@adonisjs/core/types' {\n export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}\n}\n","config/hash.ts":"import { defineConfig, drivers } from '@adonisjs/core/hash'\n\n/**\n * Hashing configuration.\n *\n * This starter uses Node.js scrypt under the hood.\n * Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback\n */\nconst hashConfig = defineConfig({\n /**\n * Default hasher used by the application.\n */\n default: 'scrypt',\n\n list: {\n /**\n * Scrypt is memory-hard, which makes brute-force attacks more expensive.\n */\n scrypt: drivers.scrypt({\n /**\n * Work factor (Node alias: N / cost).\n * Higher values increase security and CPU+memory usage.\n *\n * Tuning guideline:\n * - Start with 16384.\n * - Increase gradually (for example 32768) and benchmark login/signup latency.\n * - Keep values practical for your slowest production machine.\n *\n * Node constraint: value must be a power of two greater than 1.\n */\n cost: 16384,\n\n /**\n * Block size (Node alias: r / blockSize).\n * Increases memory and CPU linearly.\n *\n * Tuning guideline:\n * - Keep 8 unless you have a measured reason to change it.\n * - Raise only with benchmark data, because memory usage grows quickly.\n */\n blockSize: 8,\n\n /**\n * Parallelization (Node alias: p / parallelization).\n * Controls how many independent computations are performed.\n *\n * Tuning guideline:\n * - Keep 1 for most applications.\n * - Increase only after load testing if your infrastructure benefits from it.\n */\n parallelization: 1,\n\n /**\n * Maximum memory limit in bytes (Node alias: maxmem / maxMemory).\n * Hashing throws if the estimated memory usage is above this limit.\n * Node documents the check as approximately: 128 * N * r > maxmem.\n *\n * Tuning guideline:\n * - Keep this aligned with your cost/blockSize choices.\n * - Increase carefully on memory-constrained environments.\n */\n maxMemory: 33554432,\n }),\n },\n})\n\nexport default hashConfig\n\n/**\n * Inferring types for the list of hashers you have configured\n * in your application.\n */\ndeclare module '@adonisjs/core/types' {\n export interface HashersList extends InferHashers<typeof hashConfig> {}\n}\n","config/inertia.ts":"import { defineConfig } from '@adonisjs/inertia'\n\nconst inertiaConfig = defineConfig({\n /**\n * Server-side rendering options.\n */\n ssr: {\n /**\n * Toggle SSR mode for Inertia pages.\n */\n enabled: false,\n\n /**\n * Entry file used by the SSR server build.\n */\n entrypoint: 'inertia/ssr.tsx',\n },\n})\n\nexport default inertiaConfig\n","config/limiter.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, stores } from '@adonisjs/limiter'\n\n/**\n * Tests always use the in-process store so parallel suites never share\n * counters and a suite can reset them between tests; LIMITER_STORE only\n * selects the store outside tests.\n */\nconst limiterConfig = defineConfig({\n default: app.inTest ? 'memory' : (env.get('LIMITER_STORE') ?? 'redis'),\n stores: {\n redis: stores.redis({ connectionName: 'main', keyPrefix: `${env.get('ADULA_NAMESPACE')}:limiter` }),\n memory: stores.memory({}),\n },\n})\n\nexport default limiterConfig\n\ndeclare module '@adonisjs/limiter/types' {\n export interface LimitersList extends InferLimiters<typeof limiterConfig> {}\n}\n","config/logger.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'\n\nconst loggerConfig = defineConfig({\n /**\n * Default logger name used by ctx.logger and app logger calls.\n */\n default: 'app',\n\n loggers: {\n app: {\n /**\n * Toggle this logger on/off.\n */\n enabled: true,\n\n /**\n * Logger name shown in log records.\n */\n name: env.get('APP_NAME'),\n\n /**\n * Minimum level to output (trace, debug, info, warn, error, fatal).\n */\n level: env.get('LOG_LEVEL'),\n\n /**\n * Use sync destination in non-production for immediate flush.\n */\n destination: !app.inProduction ? await syncDestination() : undefined,\n\n /**\n * Configure where logs are written.\n */\n transport: {\n targets: [targets.file({ destination: 1 })],\n },\n },\n },\n})\n\nexport default loggerConfig\n\n/**\n * Inferring types for the list of loggers you have configured\n * in your application.\n */\ndeclare module '@adonisjs/core/types' {\n export interface LoggersList extends InferLoggers<typeof loggerConfig> {}\n}\n","config/mail.ts":"import env from '#start/env'\nimport { defineConfig, transports } from '@adonisjs/mail'\n\nconst smtpUser = env.get('SMTP_USERNAME')\nconst smtpPassword = env.get('SMTP_PASSWORD')\n\n/**\n * SMTP only: no external mail service. Tests fake the mailer, and a local\n * relay on 127.0.0.1:1025 is the development default.\n */\nconst mailConfig = defineConfig({\n default: env.get('MAIL_MAILER') ?? 'smtp',\n from: {\n address: env.get('MAIL_FROM_ADDRESS') ?? 'no-reply@localhost',\n name: env.get('MAIL_FROM_NAME') ?? 'عدولة',\n },\n mailers: {\n smtp: transports.smtp({\n host: env.get('SMTP_HOST') ?? '127.0.0.1',\n port: env.get('SMTP_PORT') ?? 1025,\n secure: env.get('SMTP_SECURE') ?? env.get('SMTP_PORT') === 465,\n requireTLS: env.get('SMTP_REQUIRE_TLS') ?? env.get('NODE_ENV') === 'production',\n connectionTimeout: 10000,\n greetingTimeout: 10000,\n socketTimeout: 20000,\n ...(smtpUser && smtpPassword\n ? { auth: { type: 'login' as const, user: smtpUser, pass: smtpPassword } }\n : {}),\n }),\n },\n})\n\nexport default mailConfig\n\ndeclare module '@adonisjs/mail/types' {\n export interface MailersList extends InferMailers<typeof mailConfig> {}\n}\n","config/mcp.ts":"import env from '#start/env'\nimport { defineConfig } from '@jrmc/adonis-mcp'\n\nexport default defineConfig({\n name: env.get('ADULA_NAMESPACE'),\n version: '0.1.0',\n cache: { tools: { ttlMs: 0, scope: 'private' } },\n})\n","config/queue.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig } from '@nemoventures/adonis-jobs'\nimport type { InferQueues } from '@nemoventures/adonis-jobs/types'\n\nconst queueConfig = defineConfig({\n connection: { connectionName: 'main' },\n // Own connections keep cache/Redis shutdown from interrupting queue cleanup.\n useSharedConnection: false,\n defaultPrefix: app.inTest ? `${env.get('ADULA_NAMESPACE')}-test` : env.get('ADULA_NAMESPACE'),\n defaultQueue: 'events',\n healthCheck: { enabled: false },\n queues: {\n events: {\n defaultWorkerOptions: { concurrency: 4 },\n defaultJobOptions: {\n attempts: 10,\n backoff: { type: 'exponential', delay: 1000 },\n removeOnComplete: { age: 604800 },\n removeOnFail: false,\n },\n },\n },\n})\nexport default queueConfig\ndeclare module '@nemoventures/adonis-jobs/types' {\n interface Queues extends InferQueues<typeof queueConfig> {}\n}\n","config/redis.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig } from '@adonisjs/redis'\nimport type { InferConnections } from '@adonisjs/redis/types'\n\nconst redisConfig = defineConfig({\n connection: 'main',\n connections: {\n main: {\n host: env.get('REDIS_HOST'),\n port: env.get('REDIS_PORT'),\n password: env.get('REDIS_PASSWORD'),\n // Parallel test runs isolate themselves with REDIS_TEST_DB (default 15).\n db: app.inTest ? Number(process.env.REDIS_TEST_DB ?? 15) : 0,\n keyPrefix: '',\n maxRetriesPerRequest: null,\n retryStrategy: (attempt) => (attempt > 10 ? null : attempt * 100),\n },\n },\n})\nexport default redisConfig\ndeclare module '@adonisjs/redis/types' {\n interface RedisConnections extends InferConnections<typeof redisConfig> {}\n}\n","config/session.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, stores } from '@adonisjs/session'\n\nconst sessionConfig = defineConfig({\n /**\n * Enable or disable session support globally.\n */\n enabled: true,\n\n /**\n * Cookie name storing the session identifier.\n */\n cookieName: 'adonis-session',\n\n /**\n * When set to true, the session id cookie will be deleted\n * once the user closes the browser.\n */\n clearWithBrowser: false,\n\n /**\n * Define how long to keep the session data alive without\n * any activity.\n */\n age: '2h',\n\n /**\n * Configuration for session cookie and the\n * cookie store.\n */\n cookie: {\n /**\n * Restrict the cookie to a URL path. '/' means all routes.\n */\n path: '/',\n\n /**\n * Prevent JavaScript access to the cookie in the browser.\n */\n httpOnly: true,\n\n /**\n * Send cookies only over HTTPS in production.\n */\n secure: app.inProduction,\n\n /**\n * Cross-site policy for cookie sending.\n */\n sameSite: 'lax',\n },\n\n /**\n * The store to use. Make sure to validate the environment\n * variable in order to infer the store name without any\n * errors.\n */\n // The official Japa session client persists into the memory store.\n store: app.inTest ? 'memory' : env.get('SESSION_DRIVER'),\n\n /**\n * List of configured stores. Refer documentation to see\n * list of available stores and their config.\n */\n stores: {\n /**\n * Store session data inside encrypted cookies.\n */\n cookie: stores.cookie(),\n\n /**\n * Store session data inside the configured database.\n */\n database: stores.database(),\n },\n})\n\nexport default sessionConfig\n","config/shield.ts":"import { defineConfig } from '@adonisjs/shield'\n\nconst shieldConfig = defineConfig({\n /**\n * Configure CSP policies for your app. Refer documentation\n * to learn more.\n */\n csp: {\n /**\n * Enable the Content-Security-Policy header.\n */\n enabled: false,\n\n /**\n * Per-resource CSP directives.\n */\n directives: {},\n\n /**\n * Report violations without blocking resources.\n */\n reportOnly: false,\n },\n\n /**\n * Configure CSRF protection options. Refer documentation\n * to learn more.\n */\n csrf: {\n /**\n * Enable CSRF token verification for state-changing requests.\n */\n enabled: true,\n\n /**\n * Route patterns to exclude from CSRF checks.\n * Useful for external webhooks or API endpoints.\n */\n exceptRoutes: [],\n\n /**\n * Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients.\n */\n enableXsrfCookie: true,\n\n /**\n * HTTP methods protected by CSRF validation.\n */\n methods: ['POST', 'PUT', 'PATCH', 'DELETE'],\n },\n\n /**\n * Control how your website should be embedded inside\n * iframes.\n */\n xFrame: {\n /**\n * Enable the X-Frame-Options header.\n */\n enabled: true,\n\n /**\n * Block all framing attempts. Default value is DENY.\n */\n action: 'DENY',\n },\n\n /**\n * Force browser to always use HTTPS.\n */\n hsts: {\n /**\n * Enable the Strict-Transport-Security header.\n */\n enabled: true,\n\n /**\n * HSTS policy duration remembered by browsers.\n */\n maxAge: '180 days',\n },\n\n /**\n * Disable browsers from sniffing content types and rely only\n * on the response content-type header.\n */\n contentTypeSniffing: {\n /**\n * Enable X-Content-Type-Options: nosniff.\n */\n enabled: true,\n },\n})\n\nexport default shieldConfig\n","config/static.ts":"import { defineConfig } from '@adonisjs/static'\n\n/**\n * Configuration options to tweak the static files middleware.\n * The complete set of options are documented on the\n * official documentation website.\n *\n * https://docs.adonisjs.com/guides/basics/static-file-server\n */\nconst staticServerConfig = defineConfig({\n /**\n * Enable or disable static file serving middleware.\n */\n enabled: true,\n\n /**\n * Generate ETag headers for client/proxy caching.\n */\n etag: true,\n\n /**\n * Include Last-Modified headers for conditional requests.\n */\n lastModified: true,\n\n /**\n * Policy for files starting with a dot.\n */\n dotFiles: 'ignore',\n})\n\nexport default staticServerConfig\n","config/vite.ts":"import { defineConfig } from '@adonisjs/vite'\n\nconst viteBackendConfig = defineConfig({\n /**\n * The output of vite will be written inside this\n * directory. The path should be relative from\n * the application root.\n */\n buildDirectory: 'public/assets',\n\n /**\n * The path to the manifest file generated by the\n * \"vite build\" command.\n */\n manifestFile: 'public/assets/.vite/manifest.json',\n\n /**\n * Feel free to change the value of the \"assetsUrl\" to\n * point to a CDN in production.\n */\n assetsUrl: '/assets',\n\n /**\n * HTML attributes added to generated script tags.\n */\n scriptAttributes: {\n /**\n * Execute scripts after HTML parsing is complete.\n */\n defer: true,\n },\n})\n\nexport default viteBackendConfig\n","database/migrations/1761885935168_create_users_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n protected tableName = 'users'\n\n async up() {\n this.schema.createTable(this.tableName, (table) => {\n table.increments('id').notNullable()\n table.string('full_name').nullable()\n table.string('email', 254).notNullable().unique()\n table.string('password').notNullable()\n\n table.timestamp('created_at').notNullable()\n table.timestamp('updated_at').nullable()\n })\n }\n\n async down() {\n this.schema.dropTable(this.tableName)\n }\n}\n","database/migrations/1770000000500_create_sessions_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\nexport default class extends BaseSchema {\n async up() {\n this.schema.createTable('sessions', (table) => {\n table.string('id').primary()\n table.text('data').notNullable()\n table.timestamp('expires_at').notNullable().index()\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","database/migrations/1789700000000_users_lifecycle.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\n/** Additive: a disabled user keeps their rows; only sign-in and sessions stop. */\nexport default class extends BaseSchema {\n async up() {\n this.schema.alterTable('users', (table) => {\n table.timestamp('disabled_at', { useTz: true }).nullable()\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","database/migrations/1789700001000_create_password_reset_tokens_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\n/** Single-use, hashed recovery tokens; the raw token only ever travels inside the e-mail. */\nexport default class extends BaseSchema {\n async up() {\n this.schema.createTable('password_reset_tokens', (table) => {\n table.increments('id')\n table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE')\n table.string('token_hash', 64).notNullable().unique()\n table.timestamp('expires_at', { useTz: true }).notNullable()\n table.timestamp('used_at', { useTz: true }).nullable()\n table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())\n table.index(['user_id'])\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","database/migrations/1789700002000_create_user_sessions_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\n/**\n * One row per browser session of a signed-in user. The id is the session\n * store id, so revoking a row can also delete its \"sessions\" row at once.\n */\nexport default class extends BaseSchema {\n async up() {\n this.schema.createTable('user_sessions', (table) => {\n table.string('id', 255).primary()\n table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE')\n table.string('ip', 64).nullable()\n table.string('user_agent', 512).nullable()\n table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())\n table.timestamp('last_seen_at', { useTz: true }).notNullable().defaultTo(this.now())\n table.timestamp('revoked_at', { useTz: true }).nullable()\n table.index(['user_id'])\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","database/migrations/1789700003000_create_social_accounts_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\n/** Links an OAuth identity (provider + provider id) to exactly one local user. */\nexport default class extends BaseSchema {\n async up() {\n this.schema.createTable('social_accounts', (table) => {\n table.increments('id')\n table.string('provider', 32).notNullable()\n table.string('provider_id', 255).notNullable()\n table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE')\n table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())\n table.unique(['provider', 'provider_id'])\n table.index(['user_id'])\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","inertia/app.tsx":"import { type ReactElement } from 'react'\nimport { client } from './client'\nimport Layout from '~/layouts/default'\nimport { type Data } from '@generated/data'\nimport { createRoot } from 'react-dom/client'\nimport { createInertiaApp, type ResolvedComponent } from '@inertiajs/react'\nimport { TuyauProvider } from '@adonisjs/inertia/react'\nimport { resolvePageComponent } from '@adonisjs/inertia/helpers'\nimport './css/kit.css'\n\nconst appName = import.meta.env.VITE_APP_NAME || 'adula kit'\n\ncreateInertiaApp({\n title: (title) => (title ? `${title} - ${appName}` : appName),\n resolve: (name) => {\n return resolvePageComponent<ResolvedComponent>(\n `./pages/${name}.tsx`,\n import.meta.glob<ResolvedComponent>('./pages/**/*.tsx'),\n (page: ReactElement<Data.SharedProps>) => <Layout children={page} />\n )\n },\n setup({ el, App, props }) {\n createRoot(el).render(\n <TuyauProvider client={client}>\n <App {...props} />\n </TuyauProvider>\n )\n },\n progress: {\n color: '#4B5563',\n },\n})\n","inertia/client.ts":"import { registry } from '@generated/registry'\nimport { createTuyau } from '@tuyau/core/client'\n\nexport const client = createTuyau({\n baseUrl: '/',\n registry,\n})\n\nexport const urlFor = client.urlFor\n","inertia/components/account-menu.tsx":"import { usePage } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { ChevronsUpDown, LogOut, MonitorSmartphone, UserRound } from 'lucide-react'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '~/components/ui/dropdown-menu'\n\n/** The user block at the bottom of the workspace sidebar: profile, sessions, logout. */\nexport function AccountMenu() {\n const { user } = usePage<{ user?: { fullName: string | null; email: string } }>().props\n if (!user) return null\n return (\n <DropdownMenu>\n <DropdownMenuTrigger\n aria-label=\"قائمة الحساب\"\n className=\"flex w-full items-center gap-3 rounded-lg p-1 text-start outline-none hover:bg-background focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n >\n <span className=\"grid size-9 shrink-0 place-items-center rounded-full bg-secondary font-semibold\">\n {user.fullName?.slice(0, 1) || 'م'}\n </span>\n <span className=\"min-w-0 flex-1\">\n <strong className=\"block truncate text-xs\">{user.fullName || 'حسابي'}</strong>\n <span className=\"block truncate text-[10px] text-muted-foreground\" dir=\"ltr\">\n {user.email}\n </span>\n </span>\n <ChevronsUpDown size={14} className=\"shrink-0 text-muted-foreground\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" className=\"w-56\">\n <DropdownMenuLabel className=\"truncate text-xs text-muted-foreground\" dir=\"ltr\">\n {user.email}\n </DropdownMenuLabel>\n <DropdownMenuSeparator />\n <DropdownMenuItem asChild>\n <Link route=\"profile.show\">\n <UserRound />\n الملف الشخصي\n </Link>\n </DropdownMenuItem>\n <DropdownMenuItem asChild>\n <Link route=\"account_sessions.index\">\n <MonitorSmartphone />\n الجلسات\n </Link>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <Form route=\"session.destroy\">\n <DropdownMenuItem asChild variant=\"destructive\">\n <button type=\"submit\" className=\"w-full\">\n <LogOut />\n تسجيل الخروج\n </button>\n </DropdownMenuItem>\n </Form>\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n","inertia/components/admin-nav.tsx":"import type { ReactNode } from 'react'\nimport { router, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport {\n Activity,\n History,\n KeyRound,\n Network,\n Settings,\n ShieldCheck,\n UserCog,\n Users,\n} from 'lucide-react'\nimport { calendarDisplay } from '~/components/ui/calendar_date'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { Button } from '~/components/ui/button'\n\nexport const adminLinks = [\n { href: '/admin/setup', label: 'الإعداد الأولي', icon: Settings },\n { href: '/admin/users', label: 'المستخدمون', icon: Users },\n { href: '/admin/roles', label: 'الأدوار والصلاحيات', icon: ShieldCheck },\n { href: '/admin/org-units', label: 'الهيكل التنظيمي', icon: Network },\n { href: '/admin/activity', label: 'سجل النشاط', icon: History },\n { href: '/admin/jobs', label: 'تشغيل النظام', icon: Activity },\n { href: '/admin/settings', label: 'الإعدادات', icon: Settings },\n { href: '/admin/sessions', label: 'الجلسات', icon: KeyRound },\n]\n\nexport function AdminNav() {\n const page = usePage<{ isAdmin?: boolean }>()\n if (!page.props.isAdmin) return null\n return (\n <>\n <p className=\"mb-3 mt-8 px-7 text-[11px] font-semibold text-muted-foreground\">الإدارة</p>\n <nav aria-label=\"التنقل الإداري\" className=\"space-y-1 px-4\">\n {adminLinks.map(({ href, label, icon: Icon }) => {\n const active = page.url.startsWith(href)\n return (\n <Link\n key={href}\n href={href}\n aria-current={active ? 'page' : undefined}\n className={`flex items-center gap-3 rounded-lg px-4 py-2.5 text-sm transition-colors ${active ? 'bg-accent font-semibold text-primary' : 'text-muted-foreground hover:bg-background hover:text-foreground'}`}\n >\n <Icon size={17} strokeWidth={1.6} />\n {label}\n </Link>\n )\n })}\n </nav>\n </>\n )\n}\n\nexport function ImpersonationBar() {\n const page = usePage<{\n impersonating?: boolean\n user?: { fullName: string | null; email: string }\n }>()\n if (!page.props.impersonating) return null\n return (\n <div\n role=\"status\"\n className=\"flex flex-wrap items-center justify-between gap-3 border-b border-amber-300 bg-amber-50 px-5 py-2 text-sm text-amber-900 lg:px-10\"\n >\n <span className=\"flex items-center gap-2\">\n <UserCog size={16} />\n أنت تتصفح باسم {page.props.user?.fullName || page.props.user?.email}\n </span>\n <Button size=\"sm\" variant=\"outline\" onClick={() => router.post('/impersonation/stop')}>\n إنهاء الانتحال\n </Button>\n </div>\n )\n}\n\nexport function AdminHeader({\n title,\n description,\n children,\n}: {\n title: string\n description?: string\n children?: ReactNode\n}) {\n return (\n <div className=\"mb-8 flex flex-wrap items-start justify-between gap-4\">\n <div>\n <div className=\"mb-2 flex items-center gap-2 text-xs text-muted-foreground\">\n <ShieldCheck size={15} />\n <span>الإدارة</span>\n </div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">{title}</h1>\n {description && <p className=\"mt-3 text-sm text-muted-foreground\">{description}</p>}\n </div>\n {children && <div className=\"flex flex-wrap gap-2 pt-3\">{children}</div>}\n </div>\n )\n}\n\nexport function useDateTimeFormatter() {\n const { calendar } = useUiPreferences()\n return (value: string | null | undefined) => calendarDisplay(value, calendar, true)\n}\n\nexport function formatAge(ms: number | null) {\n if (ms === null) return 'لا يوجد'\n const seconds = Math.round(ms / 1000)\n if (seconds < 60) return `قبل ${seconds} ثانية`\n const minutes = Math.round(seconds / 60)\n if (minutes < 60) return `قبل ${minutes} دقيقة`\n const hours = Math.round(minutes / 60)\n if (hours < 48) return `قبل ${hours} ساعة`\n return `قبل ${Math.round(hours / 24)} يوماً`\n}\n","inertia/components/backup-banner.tsx":"import { usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { TriangleAlert } from 'lucide-react'\n\n/** Shown to every administrator until a complete offsite backup is younger than 48 hours. */\nexport function BackupBanner() {\n const page = usePage<{ backupWarning?: boolean }>()\n if (!page.props.backupWarning) return null\n return (\n <div\n role=\"alert\"\n className=\"flex flex-wrap items-center gap-3 bg-destructive px-5 py-3 text-sm text-white lg:px-10\"\n >\n <TriangleAlert size={18} />\n <span className=\"font-semibold\">لا توجد نسخة احتياطية خارجية سليمة خلال آخر 48 ساعة.</span>\n <Link href=\"/admin/jobs\" className=\"underline underline-offset-4\">\n راجع صفحة تشغيل النظام\n </Link>\n </div>\n )\n}\n","inertia/components/flash-messages.tsx":"import { useEffect } from 'react'\nimport { usePage } from '@inertiajs/react'\nimport { toast } from 'sonner'\n\nexport function FlashMessages() {\n const { flash } = usePage()\n useEffect(() => {\n const inline = [...document.querySelectorAll('[data-flash-message]')].map((element) =>\n element.getAttribute('data-flash-message')\n )\n if (typeof flash.error === 'string' && !inline.includes(flash.error))\n toast.error(flash.error, { id: 'flash-error' })\n if (typeof flash.success === 'string' && !inline.includes(flash.success))\n toast.success(flash.success, { id: 'flash-success' })\n }, [flash])\n return null\n}\n","inertia/components/mail-test.tsx":"import { useEffect, useState } from 'react'\nimport { router } from '@inertiajs/react'\nimport type { MailTestState } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle, CardDescription } from '~/components/ui/card'\nimport {\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogFooter,\n} from '~/components/ui/dialog'\n\nexport type MailTestProps = {\n mailTest: Omit<MailTestState, 'fingerprint'> | null\n mailRecipient: string\n}\nconst labels = {\n sending: 'جارٍ الإرسال',\n pending: 'بانتظار تأكيد الاستلام',\n confirmed: 'أكد المدير وصول الرسالة',\n not_received: 'أفاد المدير بعدم وصول الرسالة',\n failed: 'تعذّر الإرسال',\n}\nexport function MailTest({\n mailTest,\n mailRecipient,\n returnTo = 'settings',\n}: MailTestProps & { returnTo?: 'settings' | 'setup' }) {\n const [busy, setBusy] = useState(false)\n const [open, setOpen] = useState(false)\n const pending =\n mailTest?.status === 'pending' && Date.now() - Date.parse(mailTest.requestedAt) < 86_400_000\n useEffect(() => {\n setOpen(pending)\n }, [mailTest?.id, pending])\n const post = (path: string, data: Record<string, string | boolean> = {}) => {\n setBusy(true)\n router.post(\n path,\n { ...data, returnTo },\n { preserveScroll: true, onFinish: () => setBusy(false) }\n )\n }\n return (\n <Card className=\"mb-6\">\n <CardHeader>\n <CardTitle>اختبار البريد الإلكتروني</CardTitle>\n <CardDescription>\n قبول الإرسال لا يثبت الوصول. نحتاج تأكيدك بعد مراجعة صندوق بريدك والبريد غير المرغوب.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <p>\n المستلم: <span dir=\"ltr\">{mailRecipient}</span>\n </p>\n <p role=\"status\">\n {mailTest?.status === 'pending' && !pending\n ? 'انتهت مهلة التأكيد؛ أرسل تجربة جديدة'\n : mailTest\n ? labels[mailTest.status]\n : 'لم يُختبر البريد بهذه الإعدادات'}\n </p>\n {mailTest && (\n <p className=\"text-xs text-muted-foreground\">\n وقت التجربة: <time dir=\"ltr\">{mailTest.requestedAt}</time> · المرجع:{' '}\n <span dir=\"ltr\">{mailTest.id}</span>\n </p>\n )}\n {mailTest?.status === 'not_received' && (\n <p className=\"text-sm\">\n راجع عنوان المرسل وإعدادات SMTP والبريد غير المرغوب، ثم أرسل تجربة جديدة.\n </p>\n )}\n <div className=\"flex flex-wrap gap-3\">\n <Button disabled={busy} onClick={() => post('/admin/settings/mail/test')}>\n {busy ? 'جارٍ التنفيذ…' : 'إرسال بريد تجريبي'}\n </Button>\n {pending && (\n <Button variant=\"outline\" disabled={busy} onClick={() => setOpen(true)}>\n تأكيد استلام البريد\n </Button>\n )}\n </div>\n {pending && (\n <p className=\"text-sm\">\n هل وصلت الرسالة؟ افتح تأكيد الاستلام لتسجيل النتيجة. ينتهي التأكيد بعد 24 ساعة.\n </p>\n )}\n </CardContent>\n <Dialog mode=\"confirm\" open={open} onOpenChange={setOpen}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>هل وصل البريد التجريبي؟</DialogTitle>\n <DialogDescription>\n تحقق من رسالة «تجربة البريد — تأكيد الاستلام» المرسلة إلى {mailRecipient}، وأن مرجعها{' '}\n {mailTest?.id}. تأكيدك يخص هذه التجربة فقط.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <Button\n disabled={busy}\n onClick={() =>\n post('/admin/settings/mail/confirm', { id: mailTest!.id, received: true })\n }\n >\n وصلت الرسالة\n </Button>\n <Button\n variant=\"outline\"\n disabled={busy}\n onClick={() =>\n post('/admin/settings/mail/confirm', { id: mailTest!.id, received: false })\n }\n >\n لم تصل الرسالة\n </Button>\n <Button variant=\"ghost\" disabled={busy} onClick={() => setOpen(false)}>\n سأتحقق لاحقًا\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Card>\n )\n}\n","inertia/components/notification-bell.tsx":"import { usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Bell } from 'lucide-react'\n\nexport function NotificationBell() {\n const page = usePage<{ unreadNotifications?: number; user?: { email: string } }>()\n if (!page.props.user) return null\n const count = page.props.unreadNotifications ?? 0\n return (\n <Link\n href=\"/notifications\"\n aria-label={count ? `الإشعارات، ${count} غير مقروء` : 'الإشعارات'}\n className=\"relative grid size-9 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-background hover:text-foreground\"\n >\n <Bell size={18} strokeWidth={1.6} />\n {count > 0 && (\n <span\n data-testid=\"unread-count\"\n className=\"absolute -end-0.5 -top-0.5 min-w-4 rounded-full bg-destructive px-1 text-center text-[10px] font-semibold leading-4 text-white\"\n >\n {count}\n </span>\n )}\n </Link>\n )\n}\n","inertia/components/ui-settings.tsx":"import { useState } from 'react'\nimport { router } from '@inertiajs/react'\nimport type { UiPreferences, CalendarPreference } from '@adula/kit'\nimport { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/card'\nimport { Button } from '~/components/ui/button'\nimport { Label } from '~/components/ui/label'\nimport { Switch } from '~/components/ui/switch'\nimport { Badge } from '~/components/ui/badge'\nimport { ResourceSelect } from '~/components/ui/resource-field'\n\nexport function UiSettings({ initial }: { initial: UiPreferences }) {\n const [value, setValue] = useState(initial)\n const [saving, setSaving] = useState(false)\n return (\n <Card className=\"mb-8\">\n <CardHeader>\n <CardTitle>التواريخ وتجربة الاستخدام</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <div className=\"max-w-md space-y-2\">\n <Label htmlFor=\"calendar-preference\">عرض التاريخ</Label>\n <ResourceSelect\n id=\"calendar-preference\"\n aria-label=\"عرض التاريخ\"\n value={value.calendar}\n options={[\n { value: 'gregory', label: 'الميلادي' },\n { value: 'islamic-umalqura', label: 'الهجري · أم القرى' },\n { value: 'both', label: 'الميلادي والهجري معًا' },\n ]}\n onChange={(calendar) =>\n setValue({ ...value, calendar: calendar as CalendarPreference })\n }\n />\n <p className=\"text-xs text-muted-foreground\">\n يشمل الجداول والتفاصيل والنماذج. عند اختيار كليهما يمكنك الإدخال بأي تقويم مع رؤية\n التاريخ المقابل.\n </p>\n </div>\n <div className=\"flex items-center justify-between gap-6\">\n <div>\n <Label htmlFor=\"confirm-dialog-close\">تأكيد إغلاق نماذج التحرير</Label>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n يحمي الإدخال عند النقر خارج النافذة أو الضغط على إغلاق أو Escape. نوافذ العرض تُغلق\n مباشرة.\n </p>\n </div>\n <Switch\n id=\"confirm-dialog-close\"\n checked={value.confirmDialogClose}\n onCheckedChange={(confirmDialogClose) => setValue({ ...value, confirmDialogClose })}\n />\n </div>\n <div className=\"flex items-center justify-between gap-6\">\n <Label htmlFor=\"page-transitions\">انتقالات سلسة بين الصفحات</Label>\n <Switch\n id=\"page-transitions\"\n checked={value.pageTransitions}\n onCheckedChange={(pageTransitions) => setValue({ ...value, pageTransitions })}\n />\n </div>\n <div className=\"flex items-start justify-between gap-6 border-t pt-5\">\n <div>\n <p className=\"text-sm font-medium\">حماية الوصول الإداري</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n تغييرات الصلاحيات تتطلب تأكيدًا. يمنع النظام إزالة آخر مدير نشط أو سحب إدارة النظام من\n حسابك الحالي.\n </p>\n </div>\n <Badge variant=\"secondary\" className=\"shrink-0\">\n مفعّلة دائمًا\n </Badge>\n </div>\n <Button\n disabled={saving}\n onClick={() =>\n router.put(\n '/admin/settings',\n {\n key: 'ui.preferences',\n scope: 'system',\n scopeId: '0',\n value: JSON.stringify(value),\n },\n {\n preserveScroll: true,\n onStart: () => setSaving(true),\n onFinish: () => setSaving(false),\n }\n )\n }\n >\n {saving ? 'جارٍ الحفظ…' : 'حفظ تفضيلات الواجهة'}\n </Button>\n </CardContent>\n </Card>\n )\n}\n","inertia/css/app.css":"/* adula:legacy-layer */\n@layer theme, base, legacy, components, utilities;\n@layer legacy {\n @scope ([data-adula-legacy]) {\n :scope {\n --gray-1: oklch(98.5% 0 0);\n --gray-2: oklch(97% 0 0);\n --gray-3: oklch(92.2% 0 0);\n --gray-4: oklch(87% 0 0);\n --gray-6: oklch(55.6% 0 0);\n --gray-7: oklch(43.9% 0 0);\n --gray-8: oklch(37.1% 0 0);\n --gray-10: oklch(26.9% 0 0);\n --gray-12: oklch(14.5% 0 0);\n }\n\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n :scope {\n height: 100%;\n font-family: system-ui, sans-serif;\n -webkit-font-smoothing: antialiased;\n background: var(--gray-2);\n color: var(--gray-10);\n font-size: 16px;\n line-height: 1.5;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n [x-cloak] {\n display: none;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n color: var(--gray-12);\n }\n\n /* Header */\n header {\n max-width: 1440px;\n margin: auto;\n padding: 0 30px;\n }\n header > div {\n display: flex;\n justify-content: space-between;\n align-items: center;\n height: 64px;\n }\n header nav {\n display: flex;\n align-items: center;\n gap: 26px;\n }\n header nav a {\n font-weight: 500;\n color: var(--gray-8);\n }\n header nav a:hover,\n header nav a.current {\n color: var(--gray-12);\n }\n\n /* Main */\n main {\n max-width: 1440px;\n margin: 0 30px;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n min-height: calc(100vh - 65px);\n background: #fff;\n border: 1px solid var(--gray-3);\n }\n\n .hero {\n padding: 100px 50px;\n max-width: 880px;\n }\n .hero h1 {\n margin-bottom: 15px;\n font-size: 52px;\n font-weight: 600;\n letter-spacing: -1px;\n line-height: 1.05;\n }\n .hero p {\n font-size: 22px;\n color: var(--gray-7);\n }\n .hero .button {\n margin-top: 30px;\n display: inline-block;\n padding: 10px 16px;\n }\n\n .cards {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n padding: 0 50px;\n border-top: 1px solid var(--gray-3);\n }\n .cards a {\n padding: 30px 40px;\n border-right: 1px solid var(--gray-3);\n }\n .cards a:first-child {\n border-left: 1px solid var(--gray-3);\n }\n .cards a:hover {\n background: var(--gray-1);\n }\n .cards h3 {\n margin-bottom: 10px;\n font-size: 20px;\n font-weight: 600;\n letter-spacing: -0.4px;\n }\n .cards p {\n color: var(--gray-6);\n }\n\n /* Form */\n .form-container {\n display: flex;\n flex-direction: column;\n justify-content: center;\n max-width: 400px;\n margin: auto;\n }\n .form-container h1 {\n font-size: 32px;\n letter-spacing: -0.5px;\n margin: 5px 0;\n }\n .form-container p {\n font-size: 18px;\n margin-bottom: 48px;\n color: var(--gray-6);\n }\n form {\n display: flex;\n flex-direction: column;\n gap: 24px;\n }\n label {\n margin-bottom: 4px;\n display: block;\n font-size: 14px;\n font-weight: 500;\n }\n input,\n textarea,\n button {\n width: 100%;\n border-radius: 4px;\n font: inherit;\n }\n input {\n height: 40px;\n border: 1px solid var(--gray-4);\n padding: 0 16px;\n }\n input[data-invalid='true'],\n textarea[data-invalid='true'] {\n border-color: #fb2c36;\n }\n input[data-invalid='true'] + div,\n textarea[data-invalid='true'] + div {\n color: #fb2c36;\n font-size: 14px;\n font-weight: 500;\n margin-top: 2px;\n }\n\n button {\n background: var(--gray-12);\n color: #fff;\n border: none;\n padding: 10px;\n font-weight: 500;\n }\n button:hover {\n background: var(--gray-10);\n }\n\n /* Alerts */\n .alert {\n background: #fff;\n position: relative;\n padding: 12px 16px;\n font-size: 14px;\n min-width: 380px;\n font-weight: 500;\n border: 1px solid var(--gray-3);\n border-radius: 10px;\n animation: scale-up 0.2s cubic-bezier(0.39, 0.575, 0.565, 1) both;\n }\n .alert-destructive {\n color: #fb2c36;\n background: #fb2c361a;\n border-color: #fb2c36;\n }\n .alert-success {\n color: #00a63e;\n background: #00a63e1a;\n border-color: #00a63e;\n }\n .flash-container {\n position: fixed;\n top: 80px;\n left: 0;\n right: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n }\n\n @keyframes scale-up {\n from {\n transform: scale(0.7);\n }\n to {\n transform: scale(1);\n }\n }\n }\n}\n","inertia/layouts/default.tsx":"import { type Data } from '@generated/data'\nimport { toast, Toaster } from 'sonner'\nimport { usePage } from '@inertiajs/react'\nimport { type ReactElement, useEffect } from 'react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { ImpersonationBar } from '~/components/admin-nav'\n\nexport default function Layout({ children }: { children: ReactElement<Data.SharedProps> }) {\n const { url, flash } = usePage()\n useEffect(() => {\n toast.dismiss()\n }, [url])\n\n useEffect(() => {\n if (typeof flash.error === 'string') {\n toast.error(flash.error)\n }\n if (typeof flash.success === 'string') {\n toast.success(flash.success)\n }\n })\n\n return (\n <div data-adula-legacy>\n {/* Impersonation must be visible on every layout, not only the workspace shell. */}\n <ImpersonationBar />\n <header>\n <div>\n <div>\n <Link route=\"home\" aria-label=\"adula kit — الرئيسية\">\n <strong dir=\"ltr\">adula kit</strong>\n </Link>\n </div>\n <div>\n <nav>\n {children.props.user ? (\n <>\n <span>{children.props.user.initials}</span>\n <Form route=\"session.destroy\">\n <button type=\"submit\">تسجيل الخروج</button>\n </Form>\n </>\n ) : (\n <>\n <Link route=\"new_account.create\">إنشاء حساب</Link>\n <Link route=\"session.create\">الدخول</Link>\n </>\n )}\n </nav>\n </div>\n </div>\n </header>\n <main>{children}</main>\n <Toaster position=\"top-center\" richColors />\n </div>\n )\n}\n","inertia/layouts/workspace.tsx":"import type { ReactNode } from 'react'\nimport { usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { ClipboardList, LayoutDashboard, ArrowUpLeft } from 'lucide-react'\nimport type { ResourceNavigation } from '@adula/kit'\nimport { Toaster } from '~/components/ui/sonner'\nimport { AccountMenu } from '~/components/account-menu'\nimport { AdminNav, ImpersonationBar, adminLinks } from '~/components/admin-nav'\nimport { FlashMessages } from '~/components/flash-messages'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { BackupBanner } from '~/components/backup-banner'\nimport { NotificationBell } from '~/components/notification-bell'\n\nexport default function Workspace({ children }: { children: ReactNode }) {\n const preferences = useUiPreferences()\n const page = usePage<{\n user?: { fullName: string | null; email: string }\n navigation: ResourceNavigation\n canInviteUsers?: boolean\n }>()\n const navigation = [\n { href: '/', label: 'نظرة عامة', icon: LayoutDashboard },\n ...(page.props.canInviteUsers\n ? [{ href: '/users/invite', label: 'دعوة مستخدم', icon: ClipboardList }]\n : []),\n ...(page.props.navigation ?? []).map((entry) => ({ ...entry, icon: ClipboardList })),\n ]\n const current =\n [...navigation, ...adminLinks].find(\n (entry) => entry.href !== '/' && page.url.startsWith(entry.href)\n )?.label ?? 'نظرة عامة'\n return (\n <div dir=\"rtl\" className=\"min-h-screen bg-background text-foreground\" data-workspace-shell>\n <FlashMessages />\n <aside className=\"fixed inset-y-0 start-0 z-30 hidden w-[244px] flex-col overflow-y-auto border-e border-border bg-white lg:flex [&>*]:shrink-0\">\n <Link href=\"/\" className=\"flex h-24 items-center gap-3 px-7\">\n <span className=\"grid size-10 place-items-center rounded-xl bg-primary text-2xl font-bold text-white\">\n ع\n </span>\n <span>\n <strong className=\"block text-xl tracking-tight\">عدولة</strong>\n <span className=\"text-[11px] text-muted-foreground\">مساحة أعمالك، بوضوح</span>\n </span>\n </Link>\n <div className=\"mx-5 mb-8 rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium\">\n مساحة العمل\n <span className=\"mt-1 block text-xs font-normal text-muted-foreground\">\n التطبيق المرجعي\n </span>\n </div>\n <p className=\"mb-3 px-7 text-[11px] font-semibold text-muted-foreground\">العمل اليومي</p>\n <nav aria-label=\"التنقل الرئيسي\" className=\"space-y-1 px-4\">\n {navigation.map(({ href, label, icon: Icon }) => {\n const active = href !== '/' ? page.url.startsWith(href) : page.url === '/'\n return (\n <Link\n key={href}\n href={href}\n aria-current={active ? 'page' : undefined}\n className={`flex items-center gap-3 rounded-lg px-4 py-3 text-sm transition-colors ${active ? 'bg-accent font-semibold text-primary' : 'text-muted-foreground hover:bg-background hover:text-foreground'}`}\n >\n <Icon size={19} strokeWidth={1.6} />\n {label}\n </Link>\n )\n })}\n </nav>\n <AdminNav />\n <div className=\"mx-5 mb-6 mt-auto border-t border-border pt-5\">\n <AccountMenu />\n <Link\n href=\"/\"\n className=\"mt-4 flex items-center justify-between text-xs text-muted-foreground\"\n >\n الصفحة الرئيسية\n <ArrowUpLeft size={14} />\n </Link>\n </div>\n </aside>\n <div className=\"lg:ps-[244px]\">\n <div className=\"flex h-[72px] items-center justify-between border-b border-border bg-white/80 px-5 lg:px-10\">\n <div className=\"flex items-center gap-3 text-xs text-muted-foreground\">\n <span className=\"font-semibold text-foreground lg:hidden\">عدولة</span>\n <span>مساحة العمل</span>\n <span>/</span>\n <span className=\"text-foreground\">{current}</span>\n </div>\n <span className=\"flex items-center gap-3 text-[11px] text-muted-foreground\">\n <NotificationBell />\n <span className=\"size-1.5 rounded-full bg-primary\" />\n بيئة التجربة\n </span>\n </div>\n <BackupBanner />\n <ImpersonationBar />\n <nav\n aria-label=\"التنقل على الجوال\"\n className=\"flex gap-5 overflow-auto border-b border-border bg-white px-5 py-3 text-xs lg:hidden\"\n >\n {navigation.map((item) => (\n <Link key={item.href} href={item.href}>\n {item.label}\n </Link>\n ))}\n </nav>\n <main className=\"mx-auto max-w-[1440px] p-5 lg:px-10 lg:py-9\">\n <div\n key={page.url.split('?')[0]}\n className={preferences.pageTransitions ? 'adula-page-enter' : undefined}\n >\n {children}\n </div>\n </main>\n </div>\n <Toaster position=\"bottom-left\" richColors />\n </div>\n )\n}\n","inertia/pages/account/profile.tsx":"import { Head, usePage } from '@inertiajs/react'\nimport { Form } from '@adonisjs/inertia/react'\nimport type { ReactElement } from 'react'\nimport { CircleCheck, KeyRound, OctagonX, UserRound } from 'lucide-react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Alert, AlertDescription } from '~/components/ui/alert'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\nimport Workspace from '~/layouts/workspace'\n\nexport default function Profile() {\n const { props, flash } = usePage<{ user?: { fullName: string | null; email: string } }>()\n const user = props.user\n return (\n <>\n <Head title=\"الملف الشخصي\" />\n <div className=\"mb-8\">\n <div className=\"mb-2 flex items-center gap-2 text-xs text-muted-foreground\">\n <UserRound size={15} />\n <span>حسابي</span>\n </div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">الملف الشخصي</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n اسمك كما يظهر للزملاء، وكلمة المرور التي تحمي حسابك.\n </p>\n </div>\n\n {typeof flash.success === 'string' && (\n <Alert className=\"mb-6\" data-flash-message={flash.success}>\n <CircleCheck />\n <AlertDescription>{flash.success}</AlertDescription>\n </Alert>\n )}\n {typeof flash.error === 'string' && (\n <Alert variant=\"destructive\" className=\"mb-6\" data-flash-message={flash.error}>\n <OctagonX />\n <AlertDescription>{flash.error}</AlertDescription>\n </Alert>\n )}\n\n <div className=\"grid gap-6 lg:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>البيانات الأساسية</CardTitle>\n <CardDescription>البريد الإلكتروني هو معرّف الدخول ولا يتغير من هنا.</CardDescription>\n </CardHeader>\n <CardContent>\n <Form route=\"profile.update\" className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input id=\"email\" dir=\"ltr\" value={user?.email ?? ''} readOnly disabled />\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"fullName\">الاسم الكامل</Label>\n <Input\n id=\"fullName\"\n name=\"fullName\"\n defaultValue={user?.fullName ?? ''}\n autoComplete=\"name\"\n required\n aria-invalid={errors.fullName ? true : undefined}\n />\n {errors.fullName && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.fullName}\n </p>\n )}\n </div>\n <Button type=\"submit\" disabled={processing}>\n حفظ البيانات\n </Button>\n </>\n )}\n </Form>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <KeyRound size={16} />\n كلمة المرور\n </CardTitle>\n <CardDescription>\n تغيير كلمة المرور يُنهي جلساتك الأخرى على بقية الأجهزة.\n </CardDescription>\n </CardHeader>\n <CardContent>\n <Form route=\"profile.password\" className=\"space-y-5\" resetOnSuccess>\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"currentPassword\">كلمة المرور الحالية</Label>\n <Input\n type=\"password\"\n id=\"currentPassword\"\n name=\"currentPassword\"\n dir=\"ltr\"\n autoComplete=\"current-password\"\n required\n aria-invalid={errors.currentPassword ? true : undefined}\n />\n {errors.currentPassword && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.currentPassword}\n </p>\n )}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"password\">كلمة المرور الجديدة</Label>\n <Input\n type=\"password\"\n id=\"password\"\n name=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.password ? true : undefined}\n />\n {errors.password && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.password}\n </p>\n )}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"passwordConfirmation\">تأكيد كلمة المرور الجديدة</Label>\n <Input\n type=\"password\"\n id=\"passwordConfirmation\"\n name=\"passwordConfirmation\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.passwordConfirmation ? true : undefined}\n />\n {errors.passwordConfirmation && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.passwordConfirmation}\n </p>\n )}\n </div>\n <Button type=\"submit\" variant=\"outline\" disabled={processing}>\n تغيير كلمة المرور\n </Button>\n </>\n )}\n </Form>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\nProfile.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/account/sessions.tsx":"import { Head, usePage } from '@inertiajs/react'\nimport { Form } from '@adonisjs/inertia/react'\nimport type { ReactElement } from 'react'\nimport { CircleCheck, MonitorSmartphone, OctagonX } from 'lucide-react'\nimport type { UserSession } from '#services/sessions'\nimport { Button } from '~/components/ui/button'\nimport { Badge } from '~/components/ui/badge'\nimport { Alert, AlertDescription } from '~/components/ui/alert'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\nimport Workspace from '~/layouts/workspace'\nimport { useDateTimeFormatter } from '~/components/admin-nav'\n\ntype Props = { sessions: UserSession[]; currentSessionId: string }\n\n/** A short human label for a user-agent string; the raw value stays in the title. */\nexport function describeAgent(agent: string | null) {\n if (!agent) return 'جهاز غير معروف'\n const browser = /Edg\\//.test(agent)\n ? 'Edge'\n : /OPR\\//.test(agent)\n ? 'Opera'\n : /Firefox\\//.test(agent)\n ? 'Firefox'\n : /Chrome\\//.test(agent)\n ? 'Chrome'\n : /Safari\\//.test(agent)\n ? 'Safari'\n : 'متصفح'\n const os = /Windows/.test(agent)\n ? 'Windows'\n : /iPhone|iPad/.test(agent)\n ? 'iOS'\n : /Android/.test(agent)\n ? 'Android'\n : /Mac OS/.test(agent)\n ? 'macOS'\n : /Linux/.test(agent)\n ? 'Linux'\n : 'نظام غير معروف'\n return `${browser} على ${os}`\n}\n\nexport default function Sessions({ sessions, currentSessionId }: Props) {\n const formatWhen = useDateTimeFormatter()\n const { flash } = usePage()\n const others = sessions.filter((session) => session.id !== currentSessionId)\n return (\n <>\n <Head title=\"الجلسات\" />\n <div className=\"mb-8 flex flex-wrap items-start justify-between gap-4\">\n <div>\n <div className=\"mb-2 flex items-center gap-2 text-xs text-muted-foreground\">\n <MonitorSmartphone size={15} />\n <span>حسابي</span>\n </div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">الجلسات</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n الأجهزة التي سجّلت الدخول منها. أنهِ أي جلسة لا تعرفها فوراً.\n </p>\n </div>\n <Form route=\"account_sessions.purge\" className=\"pt-3\">\n {({ processing }) => (\n <Button type=\"submit\" variant=\"outline\" disabled={processing || !others.length}>\n إنهاء الجلسات الأخرى ({others.length})\n </Button>\n )}\n </Form>\n </div>\n\n {typeof flash.success === 'string' && (\n <Alert className=\"mb-6\" data-flash-message={flash.success}>\n <CircleCheck />\n <AlertDescription>{flash.success}</AlertDescription>\n </Alert>\n )}\n {typeof flash.error === 'string' && (\n <Alert variant=\"destructive\" className=\"mb-6\" data-flash-message={flash.error}>\n <OctagonX />\n <AlertDescription>{flash.error}</AlertDescription>\n </Alert>\n )}\n\n <section\n className=\"overflow-hidden rounded-xl border border-border bg-white shadow-[0_2px_10px_#1c302804]\"\n aria-label=\"قائمة الجلسات\"\n >\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الجهاز</TableHead>\n <TableHead>العنوان</TableHead>\n <TableHead>آخر نشاط</TableHead>\n <TableHead>بدأت</TableHead>\n <TableHead className=\"text-start\">إجراء</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {sessions.map((session) => {\n const current = session.id === currentSessionId\n return (\n <TableRow key={session.id}>\n <TableCell title={session.userAgent ?? undefined}>\n <span className=\"flex flex-wrap items-center gap-2\">\n {describeAgent(session.userAgent)}\n {current && <Badge>الجلسة الحالية</Badge>}\n </span>\n </TableCell>\n <TableCell dir=\"ltr\" className=\"text-start tabular-nums\">\n {session.ip ?? '—'}\n </TableCell>\n <TableCell>{formatWhen(session.lastSeenAt)}</TableCell>\n <TableCell>{formatWhen(session.createdAt)}</TableCell>\n <TableCell>\n <Form route=\"account_sessions.destroy\" routeParams={{ id: session.id }}>\n {({ processing }) => (\n <Button\n type=\"submit\"\n size=\"sm\"\n variant={current ? 'destructive' : 'outline'}\n disabled={processing}\n aria-label={current ? 'إنهاء هذه الجلسة' : 'إنهاء الجلسة'}\n >\n {current ? 'إنهاء هذه الجلسة' : 'إنهاء'}\n </Button>\n )}\n </Form>\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </Table>\n </section>\n </>\n )\n}\nSessions.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/activity/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport axios from 'axios'\nimport { Filter } from 'lucide-react'\nimport type { ActivityPage } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader, useDateTimeFormatter } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Filters = { resource: string; action: string; actorId: string; from: string; to: string }\ntype Props = {\n activity: ActivityPage\n facets: { resources: string[]; actions: string[] }\n filters: Filters\n}\nconst select = 'h-9 w-full rounded-md border border-input bg-white px-2 text-sm'\n\nexport default function ActivityIndex({ activity, facets, filters }: Props) {\n const formatDateTime = useDateTimeFormatter()\n const [draft, setDraft] = useState<Filters>(filters)\n const [rows, setRows] = useState(activity.data)\n const [cursor, setCursor] = useState(activity.nextCursor)\n const [loading, setLoading] = useState(false)\n const set = (key: keyof Filters, value: string) =>\n setDraft((current) => ({ ...current, [key]: value }))\n const apply = (event: FormEvent) => {\n event.preventDefault()\n const query = Object.fromEntries(Object.entries(draft).filter(([, value]) => value))\n router.get('/admin/activity', query, { preserveState: false })\n }\n const more = async () => {\n if (!cursor) return\n setLoading(true)\n try {\n const response = await axios.get<ActivityPage>('/admin/activity', {\n params: { ...filters, cursor },\n headers: { Accept: 'application/json' },\n })\n setRows((current) => [...current, ...response.data.data])\n setCursor(response.data.nextCursor)\n } finally {\n setLoading(false)\n }\n }\n return (\n <>\n <Head title=\"سجل النشاط\" />\n <AdminHeader\n title=\"سجل النشاط\"\n description=\"كل تغيير على السجلات والإدارة يُكتب داخل معاملته.\"\n />\n <form\n onSubmit={apply}\n className=\"mb-6 grid gap-3 rounded-xl border border-border bg-white p-5 md:grid-cols-6\"\n >\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-resource\">الكيان</Label>\n <select\n id=\"filter-resource\"\n className={select}\n value={draft.resource}\n onChange={(event) => set('resource', event.target.value)}\n >\n <option value=\"\">الكل</option>\n {facets.resources.map((resource) => (\n <option key={resource} value={resource}>\n {resource}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-action\">الإجراء</Label>\n <select\n id=\"filter-action\"\n className={select}\n value={draft.action}\n onChange={(event) => set('action', event.target.value)}\n >\n <option value=\"\">الكل</option>\n {facets.actions.map((action) => (\n <option key={action} value={action}>\n {action}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-actor\">معرّف المنفّذ</Label>\n <Input\n id=\"filter-actor\"\n type=\"number\"\n min={1}\n value={draft.actorId}\n onChange={(event) => set('actorId', event.target.value)}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-from\">من تاريخ</Label>\n <Input\n id=\"filter-from\"\n type=\"date\"\n value={draft.from}\n onChange={(event) => set('from', event.target.value)}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-to\">إلى تاريخ</Label>\n <Input\n id=\"filter-to\"\n type=\"date\"\n value={draft.to}\n onChange={(event) => set('to', event.target.value)}\n />\n </div>\n <Button type=\"submit\" variant=\"outline\" className=\"self-end\">\n <Filter size={15} />\n تصفية\n </Button>\n </form>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الوقت</TableHead>\n <TableHead>الكيان</TableHead>\n <TableHead>السجل</TableHead>\n <TableHead>الإجراء</TableHead>\n <TableHead>المنفّذ</TableHead>\n <TableHead>التغييرات</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.id}>\n <TableCell className=\"whitespace-nowrap text-xs\">\n {formatDateTime(row.createdAt)}\n </TableCell>\n <TableCell>{row.resource}</TableCell>\n <TableCell>{row.recordId}</TableCell>\n <TableCell>{row.action}</TableCell>\n <TableCell dir=\"ltr\" className=\"text-xs\">\n {row.actor ?? row.actorId}\n </TableCell>\n <TableCell>\n <code className=\"line-clamp-2 max-w-md text-xs\" dir=\"ltr\">\n {JSON.stringify(row.changes)}\n </code>\n </TableCell>\n </TableRow>\n ))}\n {rows.length === 0 && (\n <TableRow>\n <TableCell colSpan={6} className=\"py-10 text-center text-muted-foreground\">\n لا نشاط مطابق.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n {cursor && (\n <div className=\"border-t border-border p-4 text-center\">\n <Button variant=\"outline\" onClick={more} disabled={loading}>\n {loading ? 'جارٍ التحميل…' : 'تحميل المزيد'}\n </Button>\n </div>\n )}\n </section>\n </>\n )\n}\nActivityIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/forbidden.tsx":"import type { ReactElement } from 'react'\nimport { Head } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { ShieldOff } from 'lucide-react'\nimport Workspace from '~/layouts/workspace'\nimport { Button } from '~/components/ui/button'\n\nexport default function Forbidden() {\n return (\n <>\n <Head title=\"غير مصرح\" />\n <div className=\"mx-auto max-w-md py-20 text-center\">\n <span className=\"mx-auto mb-6 grid size-14 place-items-center rounded-full bg-secondary text-primary\">\n <ShieldOff size={26} />\n </span>\n <h1 className=\"text-2xl font-semibold\">هذه المنطقة للمديرين فقط</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n حسابك لا يملك صلاحية الإدارة الكاملة. تواصل مع مدير النظام إن كنت تحتاجها.\n </p>\n <Button asChild variant=\"outline\" className=\"mt-8\">\n <Link href=\"/\">العودة إلى الرئيسية</Link>\n </Button>\n </div>\n </>\n )\n}\nForbidden.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/jobs/index.tsx":"import type { ReactElement, ReactNode } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { RotateCcw } from 'lucide-react'\nimport type { QueueSnapshot, RuntimeHealth } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader, formatAge, useDateTimeFormatter } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Badge } from '~/components/ui/badge'\nimport { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/card'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { health: RuntimeHealth; queues: QueueSnapshot[] }\n\nfunction Stat({\n title,\n badge,\n children,\n}: {\n title: string\n badge?: ReactNode\n children: ReactNode\n}) {\n return (\n <Card>\n <CardHeader className=\"flex flex-row items-center justify-between\">\n <CardTitle className=\"text-sm\">{title}</CardTitle>\n {badge}\n </CardHeader>\n <CardContent className=\"text-sm text-muted-foreground\">{children}</CardContent>\n </Card>\n )\n}\nconst Health = ({ healthy, ok, bad }: { healthy: boolean; ok: string; bad: string }) => (\n <Badge variant={healthy ? 'default' : 'destructive'}>{healthy ? ok : bad}</Badge>\n)\n\nexport default function JobsIndex({ health, queues }: Props) {\n const formatDateTime = useDateTimeFormatter()\n const counts: [keyof QueueSnapshot['counts'], string][] = [\n ['waiting', 'بانتظار'],\n ['active', 'قيد التنفيذ'],\n ['delayed', 'مؤجلة'],\n ['failed', 'فاشلة'],\n ['completed', 'مكتملة'],\n ]\n return (\n <>\n <Head title=\"تشغيل النظام\" />\n <AdminHeader\n title=\"تشغيل النظام\"\n description=\"حالة الخدمات والمهام الخلفية، العمليات المتعثرة والنسخ الاحتياطي.\"\n />\n <div className=\"mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-5\">\n <Stat\n title=\"المجدول\"\n badge={<Health healthy={health.heartbeats.scheduler.healthy} ok=\"سليم\" bad=\"متوقف\" />}\n >\n آخر نبضة: {formatAge(health.heartbeats.scheduler.ageMs)}\n </Stat>\n <Stat\n title=\"العامل\"\n badge={<Health healthy={health.heartbeats.worker.healthy} ok=\"سليم\" bad=\"متوقف\" />}\n >\n آخر نبضة: {formatAge(health.heartbeats.worker.ageMs)}\n </Stat>\n <Stat\n title=\"صندوق الصادر\"\n badge={\n <Health\n healthy={health.outbox.backlog === 0}\n ok=\"فارغ\"\n bad={`${health.outbox.backlog} معلّق`}\n />\n }\n >\n أقدم حدث غير منشور: {formatAge(health.outbox.oldestAgeMs)}\n </Stat>\n <Stat title=\"الأحداث المعالجة\">{health.processedEvents} حدث بلا تكرار</Stat>\n <Stat\n title=\"النسخ الاحتياطي الخارجي\"\n badge={<Health healthy={!health.backup.stale} ok=\"حديث\" bad=\"متأخر\" />}\n >\n آخر نسخة: {formatDateTime(health.backup.lastOffsite)}\n <br />\n آخر اختبار استعادة: {formatDateTime(health.backup.lastRestoreTest)}\n </Stat>\n </div>\n {queues.map((queue) => (\n <section\n key={queue.name}\n className=\"mb-8 overflow-hidden rounded-xl border border-border bg-white\"\n >\n <div className=\"flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4\">\n <h2 className=\"font-semibold\">\n الطابور <span dir=\"ltr\">{queue.name}</span>\n </h2>\n <span className=\"flex flex-wrap gap-2 text-xs\">\n {counts.map(([key, label]) => (\n <Badge\n key={key}\n variant={key === 'failed' && queue.counts[key] > 0 ? 'destructive' : 'secondary'}\n >\n {label}: {queue.counts[key]}\n </Badge>\n ))}\n </span>\n </div>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>المعرّف</TableHead>\n <TableHead>الوظيفة</TableHead>\n <TableHead>المحاولات</TableHead>\n <TableHead>سبب الفشل</TableHead>\n <TableHead>وقت الفشل</TableHead>\n <TableHead className=\"w-32\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {queue.failed.map((job) => (\n <TableRow key={job.id}>\n <TableCell dir=\"ltr\" className=\"text-xs\">\n {job.id}\n </TableCell>\n <TableCell dir=\"ltr\">{job.name}</TableCell>\n <TableCell>{job.attemptsMade}</TableCell>\n <TableCell>\n <code className=\"line-clamp-2 max-w-md text-xs\" dir=\"ltr\">\n {job.failedReason}\n </code>\n </TableCell>\n <TableCell className=\"text-xs\">{formatDateTime(job.failedAt)}</TableCell>\n <TableCell>\n <Button\n size=\"sm\"\n variant=\"outline\"\n aria-label={`إعادة محاولة ${job.id}`}\n onClick={() =>\n router.post(`/admin/jobs/${job.id}/retry`, {}, { preserveScroll: true })\n }\n >\n <RotateCcw size={14} />\n إعادة المحاولة\n </Button>\n </TableCell>\n </TableRow>\n ))}\n {queue.failed.length === 0 && (\n <TableRow>\n <TableCell colSpan={6} className=\"py-8 text-center text-muted-foreground\">\n لا وظائف فاشلة.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </section>\n ))}\n </>\n )\n}\nJobsIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/notifications/index.tsx":"import { useEffect, useState, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport axios from 'axios'\nimport { BellOff, CheckCheck } from 'lucide-react'\nimport type { NotificationPage } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { useDateTimeFormatter } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\n\ntype Props = { notifications: NotificationPage }\n\nexport default function NotificationsIndex({ notifications }: Props) {\n const formatDateTime = useDateTimeFormatter()\n const [rows, setRows] = useState(notifications.data)\n const [cursor, setCursor] = useState(notifications.nextCursor)\n const [loading, setLoading] = useState(false)\n useEffect(() => {\n setRows(notifications.data)\n setCursor(notifications.nextCursor)\n }, [notifications])\n const more = async () => {\n if (!cursor) return\n setLoading(true)\n try {\n const response = await axios.get<NotificationPage>('/notifications', {\n params: { cursor },\n headers: { Accept: 'application/json' },\n })\n setRows((current) => [...current, ...response.data.data])\n setCursor(response.data.nextCursor)\n } finally {\n setLoading(false)\n }\n }\n return (\n <>\n <Head title=\"الإشعارات\" />\n <div className=\"mb-8 flex flex-wrap items-start justify-between gap-4\">\n <div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">الإشعارات</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n {notifications.unread\n ? `${notifications.unread} إشعار غير مقروء`\n : 'لا إشعارات غير مقروءة'}\n </p>\n </div>\n <Button\n variant=\"outline\"\n disabled={notifications.unread === 0}\n onClick={() => router.post('/notifications/read-all', {}, { preserveScroll: true })}\n >\n <CheckCheck size={16} />\n تعيين الكل كمقروء\n </Button>\n </div>\n <ul className=\"space-y-3\">\n {rows.map((item) => (\n <li\n key={item.id}\n className={`flex flex-wrap items-start justify-between gap-3 rounded-xl border bg-white px-5 py-4 ${item.readAt ? 'border-border' : 'border-primary/40 shadow-[0_2px_10px_#1c302808]'}`}\n >\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex items-center gap-2 font-semibold\">\n {!item.readAt && <span className=\"size-2 rounded-full bg-primary\" />}\n {item.title}\n </p>\n <p className=\"mt-1 text-sm text-muted-foreground\">{item.body}</p>\n <p className=\"mt-2 text-xs text-muted-foreground\">{formatDateTime(item.createdAt)}</p>\n </div>\n {!item.readAt && (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() =>\n router.post(`/notifications/${item.id}/read`, {}, { preserveScroll: true })\n }\n >\n تعيين كمقروء\n </Button>\n )}\n </li>\n ))}\n {rows.length === 0 && (\n <li className=\"flex flex-col items-center gap-3 rounded-xl border border-dashed border-border p-12 text-muted-foreground\">\n <BellOff size={26} />\n لا إشعارات بعد.\n </li>\n )}\n </ul>\n {cursor && (\n <div className=\"mt-6 text-center\">\n <Button variant=\"outline\" onClick={more} disabled={loading}>\n {loading ? 'جارٍ التحميل…' : 'تحميل المزيد'}\n </Button>\n </div>\n )}\n </>\n )\n}\nNotificationsIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/org_units/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { CornerDownLeft, Pencil, Plus, Trash2 } from 'lucide-react'\nimport type { OrgUnitNode } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Badge } from '~/components/ui/badge'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\n\ntype Props = { units: OrgUnitNode[] }\ntype Draft = { parentId: number | null; name: string; type: string }\nconst select = 'h-9 rounded-md border border-input bg-white px-2 text-sm'\n\nexport default function OrgUnitsIndex({ units }: Props) {\n const [creating, setCreating] = useState<Draft | null>(null)\n const [renaming, setRenaming] = useState<OrgUnitNode | null>(null)\n const [renameTo, setRenameTo] = useState('')\n const [deleting, setDeleting] = useState<OrgUnitNode | null>(null)\n const [targets, setTargets] = useState<Record<number, string>>({})\n const options = { preserveScroll: true, preserveState: true }\n const submitCreate = (event: FormEvent) => {\n event.preventDefault()\n if (!creating) return\n router.post('/admin/org-units', creating, { ...options, onSuccess: () => setCreating(null) })\n }\n const submitRename = (event: FormEvent) => {\n event.preventDefault()\n if (!renaming) return\n router.patch(\n `/admin/org-units/${renaming.id}`,\n { name: renameTo },\n { ...options, onSuccess: () => setRenaming(null) }\n )\n }\n const move = (unit: OrgUnitNode) => {\n const chosen = targets[unit.id] ?? String(unit.parentId ?? '')\n router.post(`/admin/org-units/${unit.id}/move`, { parentId: chosen || null }, options)\n }\n const candidates = (unit: OrgUnitNode) =>\n units.filter((other) => other.id !== unit.id && !other.path.startsWith(`${unit.path}.`))\n return (\n <>\n <Head title=\"الهيكل التنظيمي\" />\n <AdminHeader\n title=\"الهيكل التنظيمي\"\n description=\"شجرة واحدة لكل المستويات؛ نقل وحدة يحدّث نطاق كل ما تحتها فوراً.\"\n >\n <Button onClick={() => setCreating({ parentId: null, name: '', type: 'department' })}>\n <Plus size={16} />\n إضافة وحدة\n </Button>\n </AdminHeader>\n <ul className=\"space-y-2\">\n {units.map((unit) => (\n <li\n key={unit.id}\n className=\"flex flex-wrap items-center gap-3 rounded-xl border border-border bg-white px-4 py-3\"\n style={{ marginInlineStart: `${(unit.depth - 1) * 24}px` }}\n >\n <span className=\"min-w-48 flex-1\">\n <strong className=\"block\">{unit.name}</strong>\n <span className=\"text-xs text-muted-foreground\">\n {unit.type}\n <span className=\"mx-2\">·</span>\n <span dir=\"ltr\">{unit.path}</span>\n </span>\n </span>\n <Badge variant=\"outline\">{unit.members} عضو</Badge>\n <span className=\"flex items-center gap-1\">\n <select\n aria-label={`نقل ${unit.name} إلى`}\n className={select}\n value={targets[unit.id] ?? String(unit.parentId ?? '')}\n onChange={(event) =>\n setTargets((current) => ({ ...current, [unit.id]: event.target.value }))\n }\n >\n <option value=\"\">— الجذر —</option>\n {candidates(unit).map((other) => (\n <option key={other.id} value={other.id}>\n {'· '.repeat(other.depth - 1)}\n {other.name}\n </option>\n ))}\n </select>\n <Button\n size=\"sm\"\n variant=\"outline\"\n aria-label={`نقل ${unit.name}`}\n onClick={() => move(unit)}\n >\n <CornerDownLeft size={14} />\n نقل\n </Button>\n </span>\n <span className=\"flex items-center gap-1\">\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إضافة وحدة فرعية تحت ${unit.name}`}\n onClick={() => setCreating({ parentId: unit.id, name: '', type: 'department' })}\n >\n <Plus size={15} />\n </Button>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إعادة تسمية ${unit.name}`}\n onClick={() => {\n setRenaming(unit)\n setRenameTo(unit.name)\n }}\n >\n <Pencil size={15} />\n </Button>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`حذف ${unit.name}`}\n onClick={() => setDeleting(unit)}\n >\n <Trash2 size={15} />\n </Button>\n </span>\n </li>\n ))}\n {units.length === 0 && (\n <li className=\"rounded-xl border border-dashed border-border p-10 text-center text-muted-foreground\">\n لا وحدات بعد؛ أضف الوحدة الجذر أولاً.\n </li>\n )}\n </ul>\n <Dialog open={creating !== null} onOpenChange={(open) => !open && setCreating(null)}>\n <DialogContent>\n <form onSubmit={submitCreate} className=\"space-y-5\">\n <DialogHeader>\n <DialogTitle>وحدة جديدة</DialogTitle>\n <DialogDescription>تُضاف تحت الوحدة الأم المختارة أو كجذر.</DialogDescription>\n </DialogHeader>\n <div className=\"space-y-1\">\n <Label htmlFor=\"unit-parent\">الوحدة الأم</Label>\n <select\n id=\"unit-parent\"\n className={`${select} w-full`}\n value={creating?.parentId ?? ''}\n onChange={(event) =>\n setCreating((draft) =>\n draft\n ? { ...draft, parentId: event.target.value ? Number(event.target.value) : null }\n : draft\n )\n }\n >\n <option value=\"\">— الجذر —</option>\n {units.map((unit) => (\n <option key={unit.id} value={unit.id}>\n {'· '.repeat(unit.depth - 1)}\n {unit.name}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"unit-name\">الاسم</Label>\n <Input\n id=\"unit-name\"\n value={creating?.name ?? ''}\n onChange={(event) =>\n setCreating((draft) => (draft ? { ...draft, name: event.target.value } : draft))\n }\n required\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"unit-type\">النوع</Label>\n <Input\n id=\"unit-type\"\n value={creating?.type ?? ''}\n onChange={(event) =>\n setCreating((draft) => (draft ? { ...draft, type: event.target.value } : draft))\n }\n required\n />\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setCreating(null)}>\n إلغاء\n </Button>\n <Button type=\"submit\">إضافة</Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n <Dialog open={renaming !== null} onOpenChange={(open) => !open && setRenaming(null)}>\n <DialogContent>\n <form onSubmit={submitRename} className=\"space-y-5\">\n <DialogHeader>\n <DialogTitle>إعادة تسمية {renaming?.name}</DialogTitle>\n </DialogHeader>\n <div className=\"space-y-1\">\n <Label htmlFor=\"rename-unit\">الاسم الجديد</Label>\n <Input\n id=\"rename-unit\"\n value={renameTo}\n onChange={(event) => setRenameTo(event.target.value)}\n required\n />\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setRenaming(null)}>\n إلغاء\n </Button>\n <Button type=\"submit\">حفظ</Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n <Dialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>حذف {deleting?.name}؟</DialogTitle>\n <DialogDescription>\n يُرفض الحذف إن كانت للوحدة وحدات فرعية أو أعضاء أو سجلات مقيدة بها.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setDeleting(null)}>\n إلغاء\n </Button>\n <Button\n type=\"button\"\n variant=\"destructive\"\n onClick={() =>\n deleting &&\n router.delete(`/admin/org-units/${deleting.id}`, {\n ...options,\n onSuccess: () => setDeleting(null),\n })\n }\n >\n حذف\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\nOrgUnitsIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/roles/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Plus, Trash2 } from 'lucide-react'\nimport type { RoleSummary } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { useConfirmAction } from '~/components/ui/confirm-action'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { roles: RoleSummary[] }\n\nexport default function RolesIndex({ roles }: Props) {\n const { confirm, confirmation } = useConfirmAction()\n const [name, setName] = useState('')\n const [level, setLevel] = useState('0')\n const create = (event: FormEvent) => {\n event.preventDefault()\n router.post(\n '/admin/roles',\n { name, permissionLevel: Number(level) },\n { preserveScroll: true, onSuccess: () => setName('') }\n )\n }\n return (\n <>\n <Head title=\"الأدوار\" />\n <AdminHeader\n title=\"الأدوار والصلاحيات\"\n description=\"كل دور يملك مصفوفة كيانات × إجراءات تُكتب مباشرة كقواعد.\"\n />\n <form\n onSubmit={create}\n className=\"mb-6 grid gap-3 rounded-xl border border-border bg-white p-5 md:grid-cols-[1fr_140px_auto]\"\n >\n <div className=\"space-y-1\">\n <Label htmlFor=\"role-name\">اسم الدور</Label>\n <Input\n id=\"role-name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n required\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"role-level\">مستوى الصلاحية</Label>\n <Input\n id=\"role-level\"\n type=\"number\"\n min={0}\n max={9}\n value={level}\n onChange={(event) => setLevel(event.target.value)}\n />\n </div>\n <Button type=\"submit\" className=\"self-end\">\n <Plus size={16} />\n إنشاء دور\n </Button>\n </form>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الدور</TableHead>\n <TableHead>المستوى</TableHead>\n <TableHead>القواعد</TableHead>\n <TableHead>المستخدمون</TableHead>\n <TableHead className=\"w-20\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {roles.map((role) => (\n <TableRow key={role.id}>\n <TableCell>\n <Link href={`/admin/roles/${role.id}`} className=\"font-semibold text-primary\">\n {role.name}\n </Link>\n </TableCell>\n <TableCell>{role.permissionLevel}</TableCell>\n <TableCell>{role.rules}</TableCell>\n <TableCell>{role.users}</TableCell>\n <TableCell>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`حذف الدور ${role.name}`}\n disabled={role.users > 0}\n onClick={() =>\n confirm({\n title: 'حذف الدور؟',\n description: `سيُحذف دور ${role.name} وقواعد صلاحياته. لا يمكن حذف دور مسند إلى مستخدمين.`,\n destructive: true,\n action: () => router.delete(`/admin/roles/${role.id}`),\n })\n }\n >\n <Trash2 size={15} />\n </Button>\n </TableCell>\n </TableRow>\n ))}\n {roles.length === 0 && (\n <TableRow>\n <TableCell colSpan={5} className=\"py-10 text-center text-muted-foreground\">\n لا أدوار بعد.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </section>\n {confirmation}\n </>\n )\n}\nRolesIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/roles/show.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { ArrowRight, Check, Pencil, Plus, Trash2, X } from 'lucide-react'\nimport type { MatrixField, MatrixSubject, RoleDetail, RoleMatrix, RoleRule } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Badge } from '~/components/ui/badge'\nimport { Checkbox } from '~/components/ui/checkbox'\nimport { useConfirmAction } from '~/components/ui/confirm-action'\nimport { ResourceSelect } from '~/components/ui/resource-field'\nimport { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { role: RoleDetail; matrix: RoleMatrix }\ntype Predicate = { field: string; operator: string; value: string }\ntype Scalar = string | number | boolean | null\n\nexport const ACTION_LABELS: Record<string, string> = {\n view: 'عرض',\n create: 'إنشاء',\n update: 'تعديل',\n delete: 'حذف',\n submit: 'اعتماد',\n cancel: 'إلغاء',\n amend: 'تعديل معتمد',\n manage: 'إدارة كاملة',\n invite: 'دعوة مستخدم',\n}\nconst OPERATORS: [string, string][] = [\n ['$eq', 'يساوي'],\n ['$ne', 'لا يساوي'],\n ['$in', 'ضمن قائمة'],\n ['$lt', 'أصغر من'],\n ['$gt', 'أكبر من'],\n ['$like', 'يشبه'],\n]\nconst NUMERIC = new Set(['integer', 'belongsTo'])\n\nfunction convert(field: MatrixField | undefined, operator: string, raw: string): Scalar | Scalar[] {\n const one = (text: string): Scalar => {\n const value = text.trim()\n if (value === 'null') return null\n if (field?.type === 'boolean') return value === 'true'\n if (field && NUMERIC.has(field.type)) return Number(value)\n return value\n }\n return operator === '$in' ? raw.split(',').map(one) : one(raw)\n}\nfunction toPredicates(conditions: RoleRule['conditions']): Predicate[] {\n return Object.entries(conditions ?? {}).flatMap(([field, condition]) =>\n condition !== null && typeof condition === 'object'\n ? Object.entries(condition).map(([operator, value]) => ({\n field,\n operator,\n value: Array.isArray(value) ? value.join(',') : String(value),\n }))\n : [{ field, operator: '$eq', value: String(condition) }]\n )\n}\nconst subjectLabel = (matrix: RoleMatrix, name: string) =>\n matrix.subjects.find((subject) => subject.name === name)?.label.ar ?? name\n\nfunction RuleEditor({\n role,\n rule,\n subject,\n onClose,\n}: {\n role: RoleDetail\n rule: RoleRule\n subject: MatrixSubject\n onClose: () => void\n}) {\n const { confirm, confirmation } = useConfirmAction()\n const [predicates, setPredicates] = useState<Predicate[]>(toPredicates(rule.conditions))\n const [fields, setFields] = useState<string[]>(rule.fields ?? [])\n const editable = subject.conditionFields.length > 0\n const update = (index: number, patch: Partial<Predicate>) =>\n setPredicates((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))\n const save = (event: FormEvent) => {\n event.preventDefault()\n const conditions: Record<string, Record<string, Scalar | Scalar[]>> = {}\n for (const predicate of predicates) {\n if (!predicate.field) continue\n conditions[predicate.field] = {\n ...(conditions[predicate.field] ?? {}),\n [predicate.operator]: convert(\n subject.conditionFields.find((field) => field.key === predicate.field),\n predicate.operator,\n predicate.value\n ),\n }\n }\n confirm({\n title: 'حفظ تغييرات الصلاحية؟',\n description:\n 'تُطبّق هذه التغييرات على جميع مستخدمي الدور فورًا. راجع الشروط والحقول قبل المتابعة.',\n action: () =>\n router.put(\n `/admin/roles/${role.id}/rules`,\n {\n subject: rule.subject,\n action: rule.action,\n inverted: rule.inverted,\n conditions,\n fields,\n },\n {\n preserveScroll: true,\n preserveState: true,\n onSuccess: (page) => {\n if (!(page as unknown as { flash?: { error?: string } }).flash?.error) onClose()\n },\n }\n ),\n })\n }\n return (\n <>\n <Dialog mode={editable ? 'edit' : 'view'} open onOpenChange={(open) => !open && onClose()}>\n <DialogContent className=\"max-w-2xl\">\n <form onSubmit={save} className=\"space-y-6\">\n <DialogHeader>\n <DialogTitle>\n {rule.inverted ? 'منع' : 'سماح'}: {subject.label.ar} / {ACTION_LABELS[rule.action]}\n </DialogTitle>\n <DialogDescription>\n {editable\n ? 'الشروط تُقيّد القاعدة بسجلات محددة، وقائمة الحقول تحصرها في حقول بعينها.'\n : 'الشروط والحقول تتطلب اختيار كيان محدد بدلاً من كل الكيانات.'}\n </DialogDescription>\n </DialogHeader>\n {editable && (\n <>\n <fieldset className=\"space-y-3\">\n <legend className=\"text-sm font-semibold\">الشروط</legend>\n {predicates.map((predicate, index) => (\n <div key={index} className=\"grid gap-2 md:grid-cols-[1fr_1fr_1fr_auto]\">\n <ResourceSelect\n aria-label={`حقل الشرط ${index + 1}`}\n value={predicate.field}\n onChange={(field) => update(index, { field })}\n options={subject.conditionFields.map((field) => ({\n value: field.key,\n label: field.label.ar,\n }))}\n placeholder=\"اختر حقلاً\"\n />\n <ResourceSelect\n aria-label={`عامل الشرط ${index + 1}`}\n value={predicate.operator}\n onChange={(operator) => update(index, { operator })}\n options={OPERATORS.map(([value, label]) => ({ value, label }))}\n />\n <Input\n aria-label={`قيمة الشرط ${index + 1}`}\n value={predicate.value}\n placeholder={predicate.operator === '$in' ? 'قيم مفصولة بفواصل' : 'القيمة'}\n onChange={(event) => update(index, { value: event.target.value })}\n />\n <Button\n type=\"button\"\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إزالة الشرط ${index + 1}`}\n onClick={() => setPredicates((rows) => rows.filter((_, i) => i !== index))}\n >\n <X size={15} />\n </Button>\n </div>\n ))}\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n onClick={() =>\n setPredicates((rows) => [...rows, { field: '', operator: '$eq', value: '' }])\n }\n >\n <Plus size={14} />\n إضافة شرط\n </Button>\n </fieldset>\n <fieldset className=\"space-y-3\">\n <legend className=\"text-sm font-semibold\">الحقول المسموح بها</legend>\n <p className=\"text-xs text-muted-foreground\">\n اتركها فارغة لتشمل القاعدة كل الحقول.\n </p>\n <div className=\"grid gap-2 sm:grid-cols-2 md:grid-cols-3\">\n {subject.fields.map((field) => (\n <label key={field.key} className=\"flex items-center gap-2 text-sm\">\n <Checkbox\n checked={fields.includes(field.key)}\n onCheckedChange={(checked) =>\n setFields((current) =>\n checked\n ? [...current, field.key]\n : current.filter((key) => key !== field.key)\n )\n }\n />\n {field.label.ar}\n </label>\n ))}\n </div>\n </fieldset>\n </>\n )}\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={onClose}>\n إلغاء\n </Button>\n {editable && <Button type=\"submit\">حفظ القاعدة</Button>}\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n {confirmation}\n </>\n )\n}\n\nexport default function RoleShow({ role, matrix }: Props) {\n const { confirm, confirmation } = useConfirmAction()\n const [busy, setBusy] = useState(false)\n const [name, setName] = useState(role.name)\n const [editing, setEditing] = useState<RoleRule | null>(null)\n const ruleFor = (subject: string, action: string, inverted: boolean) =>\n role.rules.find(\n (rule) => rule.subject === subject && rule.action === action && rule.inverted === inverted\n )\n const options = {\n preserveScroll: true,\n preserveState: true,\n onStart: () => setBusy(true),\n onFinish: () => setBusy(false),\n }\n const removeRule = (rule: RoleRule) =>\n confirm({\n title: 'حذف الصلاحية؟',\n description: `سيُحذف ${rule.inverted ? 'المنع' : 'السماح'}: ${subjectLabel(matrix, rule.subject)} / ${ACTION_LABELS[rule.action]}. يتأثر جميع مستخدمي الدور فورًا. لا يسمح النظام بإزالة آخر مدير أو صلاحيات إدارتك الحالية.`,\n destructive: true,\n label: 'تأكيد حذف الصلاحية',\n action: () => router.delete(`/admin/roles/${role.id}/rules/${rule.id}`, options),\n })\n const toggle = (subject: string, action: string, inverted: boolean) => {\n const existing = ruleFor(subject, action, inverted)\n if (existing) removeRule(existing)\n else\n confirm({\n title: inverted ? 'إضافة منع؟' : 'إضافة صلاحية؟',\n description: `${subjectLabel(matrix, subject)} / ${ACTION_LABELS[action]}. ${inverted ? 'المنع يتقدم على السماح وقد يمنع مستخدمي الدور من الوصول.' : 'سيحصل مستخدمو الدور على هذا الإجراء.'}`,\n destructive: inverted,\n action: () =>\n router.put(`/admin/roles/${role.id}/rules`, { subject, action, inverted }, options),\n })\n }\n const rename = (event: FormEvent) => {\n event.preventDefault()\n router.patch(`/admin/roles/${role.id}`, { name }, options)\n }\n const cell = (active: boolean, kind: 'allow' | 'deny') =>\n `grid size-7 place-items-center rounded-md border transition-colors ${\n active\n ? kind === 'allow'\n ? 'border-primary bg-primary text-white'\n : 'border-destructive bg-destructive text-white'\n : 'border-border text-muted-foreground hover:bg-background'\n }`\n const editingSubject = editing\n ? matrix.subjects.find((subject) => subject.name === editing.subject)\n : undefined\n return (\n <>\n <Head title={role.name} />\n <Link\n href=\"/admin/roles\"\n className=\"mb-6 inline-flex items-center gap-2 text-xs text-muted-foreground\"\n >\n <ArrowRight size={15} />\n العودة إلى الأدوار\n </Link>\n <AdminHeader title={role.name} description={`${role.users} مستخدم يحمل هذا الدور`} />\n <Alert className=\"mb-6\">\n <AlertTitle>حماية الوصول الإداري مفعّلة</AlertTitle>\n <AlertDescription>\n كل تغيير للصلاحيات يتطلب تأكيدًا. لا يمكن إزالة آخر مدير نشط أو سحب إدارة النظام من حسابك\n الحالي.\n </AlertDescription>\n </Alert>\n <form\n onSubmit={rename}\n className=\"mb-6 grid gap-3 rounded-xl border border-border bg-white p-5 md:grid-cols-[1fr_160px_auto]\"\n >\n <div className=\"space-y-1\">\n <Label htmlFor=\"role-name\">اسم الدور</Label>\n <Input\n id=\"role-name\"\n value={name}\n disabled={role.name === 'administrator' || busy}\n onChange={(event) => setName(event.target.value)}\n required\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"role-level\">مستوى الصلاحية</Label>\n <ResourceSelect\n id=\"role-level\"\n aria-label=\"مستوى الصلاحية\"\n value={String(role.permissionLevel)}\n disabled={busy}\n options={Array.from({ length: 10 }, (_, level) => ({\n value: String(level),\n label: String(level),\n }))}\n onChange={(value) =>\n confirm({\n title: 'تغيير مستوى الصلاحية؟',\n description: 'سيؤثر هذا التغيير على الحقول المتاحة لجميع مستخدمي الدور.',\n action: () =>\n router.patch(\n `/admin/roles/${role.id}`,\n { permissionLevel: Number(value) },\n options\n ),\n })\n }\n />\n </div>\n <Button\n type=\"submit\"\n variant=\"outline\"\n className=\"self-end\"\n disabled={role.name === 'administrator' || busy}\n >\n حفظ الاسم\n </Button>\n </form>\n <section className=\"mb-8 overflow-hidden rounded-xl border border-border bg-white\">\n <div className=\"flex items-center justify-between border-b border-border px-5 py-4\">\n <h2 className=\"font-semibold\">مصفوفة الصلاحيات</h2>\n <span className=\"flex items-center gap-4 text-xs text-muted-foreground\">\n <span className=\"flex items-center gap-1\">\n <span className=\"grid size-4 place-items-center rounded bg-primary text-white\">\n <Check size={10} />\n </span>\n سماح\n </span>\n <span className=\"flex items-center gap-1\">\n <span className=\"grid size-4 place-items-center rounded bg-destructive text-white\">\n <X size={10} />\n </span>\n منع (يتقدم على السماح)\n </span>\n </span>\n </div>\n <div className=\"overflow-x-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الكيان</TableHead>\n {matrix.actions.map((action) => (\n <TableHead key={action} className=\"text-center\">\n {ACTION_LABELS[action] ?? action}\n </TableHead>\n ))}\n </TableRow>\n </TableHeader>\n <TableBody>\n {matrix.subjects.map((subject) => (\n <TableRow key={subject.name}>\n <TableCell className=\"font-medium\">{subject.label.ar}</TableCell>\n {matrix.actions.map((action) => {\n if (!subject.actions.includes(action))\n return (\n <TableCell key={action} className=\"text-center text-muted-foreground\">\n —\n </TableCell>\n )\n const allow = ruleFor(subject.name, action, false)\n const deny = ruleFor(subject.name, action, true)\n return (\n <TableCell key={action} className=\"text-center\">\n <span className=\"inline-flex gap-1\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={busy}\n aria-pressed={Boolean(allow)}\n aria-label={`سماح: ${subject.label.ar} / ${ACTION_LABELS[action]}`}\n className={cell(Boolean(allow), 'allow')}\n onClick={() => toggle(subject.name, action, false)}\n >\n <Check size={14} />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={busy}\n aria-pressed={Boolean(deny)}\n aria-label={`منع: ${subject.label.ar} / ${ACTION_LABELS[action]}`}\n className={cell(Boolean(deny), 'deny')}\n onClick={() => toggle(subject.name, action, true)}\n >\n <X size={14} />\n </Button>\n </span>\n </TableCell>\n )\n })}\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </section>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <div className=\"border-b border-border px-5 py-4\">\n <h2 className=\"font-semibold\">القواعد وشروطها</h2>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n أضف شروطاً أو احصر القاعدة في حقول محددة؛ تُرفض الشروط غير المدعومة صراحةً.\n </p>\n </div>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الكيان</TableHead>\n <TableHead>الإجراء</TableHead>\n <TableHead>النوع</TableHead>\n <TableHead>الشروط</TableHead>\n <TableHead>الحقول</TableHead>\n <TableHead className=\"w-24\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {role.rules.map((rule) => (\n <TableRow key={rule.id}>\n <TableCell>{subjectLabel(matrix, rule.subject)}</TableCell>\n <TableCell>{ACTION_LABELS[rule.action] ?? rule.action}</TableCell>\n <TableCell>\n {rule.inverted ? <Badge variant=\"destructive\">منع</Badge> : <Badge>سماح</Badge>}\n </TableCell>\n <TableCell>\n <code className=\"text-xs\" dir=\"ltr\">\n {rule.conditions ? JSON.stringify(rule.conditions) : '—'}\n </code>\n </TableCell>\n <TableCell>\n <span className=\"flex flex-wrap gap-1\">\n {rule.fields?.map((field) => (\n <Badge key={field} variant=\"outline\">\n {field}\n </Badge>\n )) ?? <span className=\"text-muted-foreground\">كل الحقول</span>}\n </span>\n </TableCell>\n <TableCell>\n <span className=\"inline-flex gap-1\">\n <Button\n size=\"sm\"\n variant=\"outline\"\n disabled={busy}\n aria-label={`${rule.subject === 'all' ? 'تفاصيل' : 'تحرير'} قاعدة ${subjectLabel(matrix, rule.subject)} / ${ACTION_LABELS[rule.action]}`}\n onClick={() => setEditing(rule)}\n >\n <Pencil size={15} />\n {rule.subject === 'all' ? 'تفاصيل' : 'تحرير'}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n className=\"text-destructive\"\n disabled={busy}\n aria-label={`حذف قاعدة ${subjectLabel(matrix, rule.subject)} / ${ACTION_LABELS[rule.action]}`}\n onClick={() => removeRule(rule)}\n >\n <Trash2 size={15} />\n حذف\n </Button>\n </span>\n </TableCell>\n </TableRow>\n ))}\n {role.rules.length === 0 && (\n <TableRow>\n <TableCell colSpan={6} className=\"py-8 text-center text-muted-foreground\">\n لا قواعد بعد؛ فعّل خلية في المصفوفة أعلاه.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </section>\n {editing && editingSubject && (\n <RuleEditor\n role={role}\n rule={editing}\n subject={editingSubject}\n onClose={() => setEditing(null)}\n />\n )}\n {confirmation}\n </>\n )\n}\nRoleShow.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/sessions/index.tsx":"import { Head, usePage } from '@inertiajs/react'\nimport { Form } from '@adonisjs/inertia/react'\nimport type { ReactElement } from 'react'\nimport { CircleCheck, OctagonX, ShieldCheck } from 'lucide-react'\nimport type { ActiveSession } from '#services/sessions'\nimport { Button } from '~/components/ui/button'\nimport { Badge } from '~/components/ui/badge'\nimport { Alert, AlertDescription } from '~/components/ui/alert'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\nimport Workspace from '~/layouts/workspace'\nimport { describeAgent } from '~/pages/account/sessions'\nimport { useDateTimeFormatter } from '~/components/admin-nav'\n\ntype Props = { sessions: ActiveSession[]; currentSessionId: string }\n\nexport default function AdminSessions({ sessions, currentSessionId }: Props) {\n const formatWhen = useDateTimeFormatter()\n const { flash } = usePage()\n return (\n <>\n <Head title=\"الجلسات النشطة\" />\n <div className=\"mb-8\">\n <div className=\"mb-2 flex items-center gap-2 text-xs text-muted-foreground\">\n <ShieldCheck size={15} />\n <span>الإدارة</span>\n </div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">الجلسات النشطة</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n كل الجلسات المفتوحة لجميع المستخدمين. إنهاء الجلسة يُخرج صاحبها فوراً.\n </p>\n </div>\n\n {typeof flash.success === 'string' && (\n <Alert className=\"mb-6\" data-flash-message={flash.success}>\n <CircleCheck />\n <AlertDescription>{flash.success}</AlertDescription>\n </Alert>\n )}\n {typeof flash.error === 'string' && (\n <Alert variant=\"destructive\" className=\"mb-6\" data-flash-message={flash.error}>\n <OctagonX />\n <AlertDescription>{flash.error}</AlertDescription>\n </Alert>\n )}\n\n <section\n className=\"overflow-hidden rounded-xl border border-border bg-white shadow-[0_2px_10px_#1c302804]\"\n aria-label=\"قائمة الجلسات النشطة\"\n >\n <div className=\"flex items-center gap-3 border-b border-border px-6 py-5\">\n <h2 className=\"text-sm font-semibold\">الجلسات</h2>\n <Badge variant=\"secondary\" className=\"font-normal tabular-nums\">\n {sessions.length} نشطة\n </Badge>\n </div>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>المستخدم</TableHead>\n <TableHead>الجهاز</TableHead>\n <TableHead>العنوان</TableHead>\n <TableHead>آخر نشاط</TableHead>\n <TableHead className=\"text-start\">إجراء</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {sessions.map((session) => {\n const current = session.id === currentSessionId\n return (\n <TableRow key={session.id}>\n <TableCell>\n <span className=\"block font-medium\">{session.fullName || '—'}</span>\n <span className=\"block text-xs text-muted-foreground\" dir=\"ltr\">\n {session.email}\n </span>\n </TableCell>\n <TableCell title={session.userAgent ?? undefined}>\n <span className=\"flex flex-wrap items-center gap-2\">\n {describeAgent(session.userAgent)}\n {current && <Badge>جلستك</Badge>}\n </span>\n </TableCell>\n <TableCell dir=\"ltr\" className=\"text-start tabular-nums\">\n {session.ip ?? '—'}\n </TableCell>\n <TableCell>{formatWhen(session.lastSeenAt)}</TableCell>\n <TableCell>\n <Form route=\"admin_sessions.destroy\" routeParams={{ id: session.id }}>\n {({ processing }) => (\n <Button\n type=\"submit\"\n size=\"sm\"\n variant=\"outline\"\n disabled={processing}\n aria-label={`إنهاء جلسة ${session.email}`}\n >\n إنهاء\n </Button>\n )}\n </Form>\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </Table>\n </section>\n </>\n )\n}\nAdminSessions.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/settings/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { Lock, Pencil, Plus, Trash2 } from 'lucide-react'\nimport type { SettingRow, SettingScope } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { UiSettings } from '~/components/ui-settings'\nimport { MailTest, type MailTestProps } from '~/components/mail-test'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { useConfirmAction } from '~/components/ui/confirm-action'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Badge } from '~/components/ui/badge'\nimport { Textarea } from '~/components/ui/textarea'\nimport { Tabs, TabsList, TabsTrigger } from '~/components/ui/tabs'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { settings: SettingRow[]; scope: SettingScope; scopeId: string } & MailTestProps\ntype Draft = { id: number | null; key: string; value: string }\nconst SCOPES: [SettingScope, string][] = [\n ['system', 'النظام'],\n ['org_unit', 'وحدة تنظيمية'],\n ['user', 'مستخدم'],\n]\n\nfunction jsonError(text: string) {\n try {\n JSON.parse(text)\n return ''\n } catch {\n return 'القيمة ليست JSON صالحاً'\n }\n}\n\nexport default function SettingsIndex({\n settings,\n scope,\n scopeId,\n mailTest,\n mailRecipient,\n}: Props) {\n const preferences = useUiPreferences()\n const { confirm, confirmation } = useConfirmAction()\n const [target, setTarget] = useState(scopeId)\n const [draft, setDraft] = useState<Draft | null>(null)\n const options = { preserveScroll: true, preserveState: true }\n const visit = (nextScope: SettingScope, nextId: string) =>\n router.get('/admin/settings', { scope: nextScope, scopeId: nextId }, { preserveState: true })\n const error = draft ? jsonError(draft.value) : ''\n const save = (event: FormEvent) => {\n event.preventDefault()\n if (!draft || error) return\n router.put(\n '/admin/settings',\n { key: draft.key, scope, scopeId, value: draft.value },\n { ...options, onSuccess: () => setDraft(null) }\n )\n }\n return (\n <>\n <Head title=\"الإعدادات\" />\n <AdminHeader\n title=\"الإعدادات\"\n description=\"اضبط التواريخ وسلوك الواجهة، وراجع إعدادات النظام.\"\n >\n <Button\n disabled={scope !== 'system' && !/^\\d+$/.test(scopeId)}\n onClick={() => setDraft({ id: null, key: '', value: '' })}\n >\n <Plus size={16} />\n إضافة إعداد\n </Button>\n </AdminHeader>\n <UiSettings key={JSON.stringify(preferences)} initial={preferences} />\n <MailTest mailTest={mailTest} mailRecipient={mailRecipient} />\n <h2 className=\"mb-4 text-lg font-semibold\">إعدادات متقدمة</h2>\n <div className=\"mb-6 flex flex-wrap items-center gap-4\">\n <Tabs value={scope} onValueChange={(value) => visit(value as SettingScope, '')}>\n <TabsList>\n {SCOPES.map(([value, label]) => (\n <TabsTrigger key={value} value={value}>\n {label}\n </TabsTrigger>\n ))}\n </TabsList>\n </Tabs>\n {scope !== 'system' && (\n <form\n onSubmit={(event) => {\n event.preventDefault()\n visit(scope, target)\n }}\n className=\"flex items-end gap-2\"\n >\n <div className=\"space-y-1\">\n <Label htmlFor=\"scope-id\">\n {scope === 'org_unit' ? 'معرّف الوحدة' : 'معرّف المستخدم'}\n </Label>\n <Input\n id=\"scope-id\"\n type=\"number\"\n min={1}\n value={target}\n onChange={(event) => setTarget(event.target.value)}\n className=\"w-40 bg-white\"\n />\n </div>\n <Button type=\"submit\" variant=\"outline\">\n عرض\n </Button>\n </form>\n )}\n </div>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>المفتاح</TableHead>\n <TableHead>القيمة</TableHead>\n <TableHead>الحماية</TableHead>\n <TableHead className=\"w-24\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {settings.map((row) => (\n <TableRow key={row.id}>\n <TableCell dir=\"ltr\" className=\"font-medium\">\n {row.key}\n </TableCell>\n <TableCell>\n <code className=\"line-clamp-2 max-w-md text-xs\" dir=\"ltr\">\n {JSON.stringify(row.value)}\n </code>\n </TableCell>\n <TableCell>\n {row.readOnly ? (\n <Badge variant=\"secondary\">\n <Lock size={11} />\n للقراءة فقط\n </Badge>\n ) : (\n <span className=\"text-xs text-muted-foreground\">قابل للتحرير</span>\n )}\n </TableCell>\n <TableCell>\n <span className=\"inline-flex gap-1\">\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`تحرير ${row.key}`}\n disabled={row.readOnly}\n onClick={() =>\n setDraft({\n id: row.id,\n key: row.key,\n value: JSON.stringify(row.value, null, 2),\n })\n }\n >\n <Pencil size={15} />\n </Button>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`حذف ${row.key}`}\n disabled={row.readOnly}\n onClick={() =>\n confirm({\n title: 'حذف الإعداد؟',\n description: `سيُحذف ${row.key} وتعود القيم الافتراضية إن وُجدت.`,\n destructive: true,\n action: () => router.delete(`/admin/settings/${row.id}`, options),\n })\n }\n >\n <Trash2 size={15} />\n </Button>\n </span>\n </TableCell>\n </TableRow>\n ))}\n {settings.length === 0 && (\n <TableRow>\n <TableCell colSpan={4} className=\"py-10 text-center text-muted-foreground\">\n {scope !== 'system' && !/^\\d+$/.test(scopeId)\n ? 'أدخل معرّف النطاق لعرض إعداداته.'\n : 'لا إعدادات في هذا النطاق.'}\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </section>\n <Dialog open={draft !== null} onOpenChange={(open) => !open && setDraft(null)}>\n <DialogContent>\n <form onSubmit={save} className=\"space-y-5\">\n <DialogHeader>\n <DialogTitle>{draft?.id ? `تحرير ${draft.key}` : 'إعداد جديد'}</DialogTitle>\n <DialogDescription>القيمة تُحفظ كما هي بصيغة JSON.</DialogDescription>\n </DialogHeader>\n <div className=\"space-y-1\">\n <Label htmlFor=\"setting-key\">المفتاح</Label>\n <Input\n id=\"setting-key\"\n dir=\"ltr\"\n value={draft?.key ?? ''}\n disabled={Boolean(draft?.id)}\n onChange={(event) =>\n setDraft((current) =>\n current ? { ...current, key: event.target.value } : current\n )\n }\n required\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"setting-value\">القيمة (JSON)</Label>\n <Textarea\n id=\"setting-value\"\n dir=\"ltr\"\n rows={6}\n value={draft?.value ?? ''}\n aria-invalid={Boolean(error)}\n onChange={(event) =>\n setDraft((current) =>\n current ? { ...current, value: event.target.value } : current\n )\n }\n required\n />\n {error && <p className=\"text-xs text-destructive\">{error}</p>}\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setDraft(null)}>\n إلغاء\n </Button>\n <Button type=\"submit\" disabled={Boolean(error) || !draft?.key}>\n حفظ\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n {confirmation}\n </>\n )\n}\nSettingsIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/setup/index.tsx":"import { Link } from '@adonisjs/inertia/react'\nimport { useState, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport type { setupSnapshot } from '#services/initial_setup'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { MailTest } from '~/components/mail-test'\nimport { Button } from '~/components/ui/button'\nimport { Card, CardHeader, CardTitle, CardDescription, CardContent } from '~/components/ui/card'\nimport { Badge } from '~/components/ui/badge'\nimport {\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogFooter,\n} from '~/components/ui/dialog'\n\ntype Props = Awaited<ReturnType<typeof setupSnapshot>>\nconst guides = {\n identity: {\n title: 'هوية الشركة',\n text: 'راجع الاسم والشعار والألوان والخطوط قبل الاعتماد. الهوية المثبتة مملوكة للمشروع؛ تُستكمل في docs/design-identity.md وcompany-identity.json وملفات brand. الاعتماد هنا يسجل موافقتك على الهوية المعروضة ولا يغيّر ملفاتها.',\n },\n mail: {\n title: 'إعداد البريد',\n text: 'اضبط SMTP_HOST وSMTP_PORT وSMTP_USERNAME وSMTP_PASSWORD وMAIL_FROM_NAME وMAIL_FROM_ADDRESS في بيئة التطبيق، ثم أعد تشغيل الخادم والعامل. استخدم عنوان مرسل موثقًا لدى مزودك واتبع تعليماته لتوثيق النطاق. لا تضع كلمات المرور في الإعدادات العامة أو مستودع المشروع. بعد ذلك أرسل تجربة وأكد استلامها.',\n },\n storage: {\n title: 'إعداد الملفات',\n text: 'التخزين المحلي متاح افتراضيًا ويحتاج مساحة دائمة. لاستخدام S3 اضبط DRIVE_DISK=s3 وAWS_ACCESS_KEY_ID وAWS_SECRET_ACCESS_KEY وAWS_REGION وS3_BUCKET وAWS_ENDPOINT عند الحاجة. أعد التشغيل ثم افحص التخزين. نقل الملفات القائمة يحتاج أمر adula:storage:migrate الموثق؛ تغيير الإعداد وحده لا ينقلها.',\n },\n runtime: {\n title: 'تشغيل الخدمات الخلفية',\n text: 'تحقق من اتصالات PostgreSQL وRedis في بيئة التطبيق. شغّل node ace adula:worker باستمرار، وعملية واحدة فقط من node ace scheduler:run تحت مدير عمليات أو خدمات النشر. نجاح الاتصال لا يثبت تنفيذ المهام؛ راقب نبضات التشغيل والطابور في صفحة تشغيل النظام.',\n },\n backup: {\n title: 'النسخ الاحتياطي والاستعادة',\n text: 'اضبط BACKUP_S3_ENDPOINT وBACKUP_S3_BUCKET وBACKUP_S3_REGION وBACKUP_S3_ACCESS_KEY_ID وBACKUP_S3_SECRET_ACCESS_KEY في بيئة التشغيل. شغّل خدمة النسخ المرفقة وحدد سياسة الاحتفاظ وفق احتياجك. افحص النسخة عبر node ace backup:verify، ثم نفّذ backup:restore-test على نسخة محمّلة من المخزن الخارجي. وجود الملفات لا يثبت استعادة سجل ومرفقه. لا تبدأ الاستعادة على قاعدة التطبيق الحالية.',\n },\n oauth: {\n title: 'الدخول الخارجي — اختياري',\n text: 'اضبط APP_URL ثم بيانات GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET أو GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET. سجل عنوان الرجوع APP_URL/oauth/google/callback أو APP_URL/oauth/github/callback لدى المزود. أعد التشغيل وجرّب الدخول في جلسة أخرى مع إبقاء جلسة المدير متاحة. لا يُعد المزود مختبرًا حتى ينجح الدخول الفعلي.',\n },\n}\n\nexport default function SetupIndex(props: Props) {\n const [guide, setGuide] = useState<keyof typeof guides | null>(null)\n const [busy, setBusy] = useState(false)\n const post = (path: string, data = {}) => {\n setBusy(true)\n router.post(path, data, {\n preserveScroll: true,\n onFinish: () => setBusy(false),\n onSuccess: () => setGuide(null),\n })\n }\n const checkLabel = (check: Props['storage']) =>\n !check\n ? 'لم يُختبر'\n : !check.fresh\n ? 'يلزم فحص حديث'\n : check.status === 'passed'\n ? 'نجح الفحص'\n : check.status === 'failed'\n ? 'فشل الفحص'\n : 'الفحص جارٍ أو انقطع؛ يمكن إعادته بعد خمس دقائق'\n const runtimeReady =\n props.health.heartbeats.worker.healthy && props.health.heartbeats.scheduler.healthy\n return (\n <>\n <Head title=\"الإعداد الأولي\" />\n <AdminHeader\n title=\"الإعداد الأولي\"\n description=\"أكمل الخطوات واختبر نتائجها. تُحفظ حالتك لتتابع لاحقًا؛ اكتمال هذه الصفحة لا يحل محل قبول بيئة الإنتاج.\"\n >\n <Button variant=\"outline\" disabled={busy} onClick={() => router.reload()}>\n تحديث الحالة\n </Button>\n </AdminHeader>\n <div className=\"mb-6 rounded-lg border bg-muted/30 p-4 text-sm\">\n {props.environment === 'development'\n ? 'بيئة تطوير: يمكنك تجربة التطبيق قبل ربط الخدمات الخارجية.'\n : 'بيئة إنتاج: عالج الخدمات غير المختبرة أو المتعطلة قبل الاعتماد على وظائفها.'}{' '}\n بيانات الاتصال والأسرار تُضبط في بيئة التطبيق، ولا تظهر هنا.\n </div>\n <div className=\"mb-6 grid gap-5 xl:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>١. هوية الشركة وتجربة الاستخدام</CardTitle>\n <CardDescription>{props.brand.company}</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Badge variant=\"secondary\">\n {props.identityConfirmed ? 'اعتمد المدير الهوية الحالية' : 'تحتاج مراجعة المدير'}\n </Badge>\n <p className=\"text-sm\">\n {props.brand.logo\n ? 'يوجد شعار ضمن الهوية المثبتة.'\n : 'لم يُقدّم شعار؛ راجع الهوية المؤقتة قبل اعتمادها.'}\n </p>\n <div className=\"flex gap-3\">\n <Button onClick={() => setGuide('identity')}>مراجعة الهوية</Button>\n <Button variant=\"outline\" asChild>\n <Link href=\"/admin/settings\">التقويم وتفضيلات الواجهة</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>٢. الإشعارات داخل التطبيق</CardTitle>\n <CardDescription>تحقق من وصول إشعار لحسابك وظهور حالة القراءة.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Badge variant=\"secondary\">\n {props.notification?.read\n ? 'تمت قراءة الإشعار التجريبي'\n : props.notification\n ? 'بانتظار قراءة الإشعار'\n : 'لم يُختبر'}\n </Badge>\n <p className=\"text-sm\">\n الإشعارات الداخلية متاحة. تفعيل البريد لا يحوّل كل إشعار تلقائيًا إلى رسالة بريدية.\n </p>\n <div className=\"flex gap-3\">\n <Button disabled={busy} onClick={() => post('/admin/setup/notification')}>\n إرسال إشعار تجريبي\n </Button>\n <Button variant=\"outline\" asChild>\n <Link href=\"/notifications\">فتح الإشعارات</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n </div>\n <MailTest mailTest={props.mailTest} mailRecipient={props.mailRecipient} returnTo=\"setup\" />\n <Button variant=\"link\" className=\"mb-6\" onClick={() => setGuide('mail')}>\n كيفية ضبط البريد بأمان\n </Button>\n <div className=\"grid gap-5 xl:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>٣. الملفات والمرفقات</CardTitle>\n <CardDescription>\n {props.storageDisk === 'local' ? 'التخزين المحلي' : 'تخزين S3'}\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Badge variant=\"secondary\">{checkLabel(props.storage)}</Badge>\n <p className=\"text-sm\">\n الفحص يكتب ملفًا تجريبيًا صغيرًا، يقرأه ويتحقق من مطابقته، ثم يحذفه.\n </p>\n {props.storage && (\n <p className=\"text-xs\" dir=\"ltr\">\n {props.storage.checkedAt}\n </p>\n )}\n <div className=\"flex gap-3\">\n <Button disabled={busy} onClick={() => post('/admin/setup/check/storage')}>\n فحص التخزين\n </Button>\n <Button variant=\"outline\" onClick={() => setGuide('storage')}>\n إعداد التخزين\n </Button>\n </div>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>٤. الاتصال والخدمات الخلفية</CardTitle>\n <CardDescription>قاعدة البيانات وRedis والعامل والمجدول</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <p>الاتصال: {checkLabel(props.infrastructure)}</p>\n <p>العامل: {props.health.heartbeats.worker.healthy ? 'نشط' : 'لا توجد نبضة حديثة'}</p>\n <p>\n المجدول: {props.health.heartbeats.scheduler.healthy ? 'نشط' : 'لا توجد نبضة حديثة'}\n </p>\n <Badge variant=\"secondary\">\n {runtimeReady ? 'نبضات التشغيل حديثة' : 'تحتاج تشغيلًا أو فحصًا'}\n </Badge>\n <div className=\"flex flex-wrap gap-3\">\n <Button disabled={busy} onClick={() => post('/admin/setup/check/infrastructure')}>\n فحص الاتصال\n </Button>\n <Button variant=\"outline\" onClick={() => setGuide('runtime')}>\n تعليمات التشغيل\n </Button>\n <Button variant=\"link\" asChild>\n <Link href=\"/admin/jobs\">تشغيل النظام</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>٥. النسخ الاحتياطي</CardTitle>\n <CardDescription>نسخة خارجية مع اختبار استعادة مستقل</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <p>\n {props.backup.configured ? 'بيانات المخزن الخارجي موجودة' : 'المخزن الخارجي غير مهيأ'}\n </p>\n <p>\n {props.backup.inspection?.healthy &&\n Date.now() - Date.parse(props.backup.inspection.checkedAt) < 86_400_000\n ? 'فحص ملفات النسخة حديث وناجح'\n : 'لا يوجد فحص حديث ناجح لملفات النسخة'}\n </p>\n <p>\n اختبار الاستعادة:{' '}\n {props.backup.restore?.status === 'passed' && props.backup.restore.fileVerified\n ? 'نجحت استعادة سجل ومرفقه؛ يلزم التأكد من أن مصدر النسخة خارجي'\n : props.backup.restore?.status === 'failed'\n ? 'فشل آخر اختبار استعادة'\n : 'لم تُثبت استعادة سجل ومرفقه'}\n </p>\n <Button variant=\"outline\" onClick={() => setGuide('backup')}>\n إعداد النسخ والتحقق من الاستعادة\n </Button>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>٦. الدخول الخارجي</CardTitle>\n <CardDescription>اختياري؛ تسجيل الدخول المحلي يبقى متاحًا</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {props.oauth.map((provider) => (\n <p key={provider.provider}>\n <span dir=\"ltr\">{provider.provider}</span>:{' '}\n {provider.verifiedAt\n ? `نجح دخول فعلي بتاريخ ${provider.verifiedAt}`\n : provider.configured\n ? 'مهيأ ولم يُثبت الدخول'\n : 'غير مفعّل'}\n </p>\n ))}\n <Button variant=\"outline\" onClick={() => setGuide('oauth')}>\n إعداد الدخول الخارجي\n </Button>\n </CardContent>\n </Card>\n </div>\n <Dialog mode=\"view\" open={guide !== null} onOpenChange={(open) => !open && setGuide(null)}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{guide ? guides[guide].title : ''}</DialogTitle>\n <DialogDescription>{guide ? guides[guide].text : ''}</DialogDescription>\n </DialogHeader>\n <DialogFooter>\n {guide === 'identity' && (\n <Button\n disabled={busy}\n onClick={() => post('/admin/setup/identity', { confirmed: true })}\n >\n أعتمد الهوية الحالية\n </Button>\n )}\n <Button variant=\"outline\" onClick={() => setGuide(null)}>\n إغلاق\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\nSetupIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/users/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Search } from 'lucide-react'\nimport type { UserPage } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Badge } from '~/components/ui/badge'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { users: UserPage; search: string }\n\nexport default function UsersIndex({ users, search }: Props) {\n const { canInviteUsers } = usePage<{ canInviteUsers?: boolean }>().props\n const [term, setTerm] = useState(search)\n const submit = (event: FormEvent) => {\n event.preventDefault()\n router.get('/admin/users', term ? { search: term } : {}, { preserveState: true })\n }\n const next = users.nextCursor\n ? `/admin/users?cursor=${users.nextCursor}${search ? `&search=${encodeURIComponent(search)}` : ''}`\n : null\n return (\n <>\n <Head title=\"المستخدمون\" />\n <AdminHeader title=\"المستخدمون\" description=\"الأدوار والوحدات وحالة الحساب لكل مستخدم.\">\n {canInviteUsers && (\n <Button asChild>\n <Link href=\"/users/invite\">إضافة مستخدم</Link>\n </Button>\n )}\n </AdminHeader>\n <form onSubmit={submit} role=\"search\" className=\"mb-6 flex flex-wrap gap-2\">\n <Input\n aria-label=\"البحث عن مستخدم\"\n placeholder=\"البريد أو الاسم\"\n value={term}\n onChange={(event) => setTerm(event.target.value)}\n className=\"max-w-sm bg-white\"\n />\n <Button type=\"submit\" variant=\"outline\">\n <Search size={16} />\n بحث\n </Button>\n </form>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>المستخدم</TableHead>\n <TableHead>الأدوار</TableHead>\n <TableHead>الوحدات</TableHead>\n <TableHead>الحالة</TableHead>\n <TableHead className=\"w-24\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {users.data.map((user) => (\n <TableRow key={user.id}>\n <TableCell>\n <strong className=\"block\">{user.fullName || '—'}</strong>\n <span className=\"text-xs text-muted-foreground\" dir=\"ltr\">\n {user.email}\n </span>\n </TableCell>\n <TableCell>\n <span className=\"flex flex-wrap gap-1\">\n {user.roles.length === 0 && (\n <span className=\"text-xs text-muted-foreground\">بلا أدوار</span>\n )}\n {user.roles.map((role) => (\n <Badge key={role.id} variant=\"secondary\">\n {role.role}\n {role.orgUnit ? ` · ${role.orgUnit}` : ''}\n </Badge>\n ))}\n </span>\n </TableCell>\n <TableCell>\n <span className=\"flex flex-wrap gap-1\">\n {user.orgUnits.map((unit) => (\n <Badge key={unit.id} variant=\"outline\">\n {unit.name}\n </Badge>\n ))}\n </span>\n </TableCell>\n <TableCell>\n {user.disabledAt ? (\n <Badge variant=\"destructive\">معطّل</Badge>\n ) : (\n <Badge>نشط</Badge>\n )}\n </TableCell>\n <TableCell>\n <Button asChild size=\"sm\" variant=\"ghost\">\n <Link href={`/admin/users/${user.id}`}>تفاصيل</Link>\n </Button>\n </TableCell>\n </TableRow>\n ))}\n {users.data.length === 0 && (\n <TableRow>\n <TableCell colSpan={5} className=\"py-10 text-center text-muted-foreground\">\n لا يوجد مستخدمون مطابقون.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n {next && (\n <div className=\"border-t border-border p-4 text-center\">\n <Button asChild variant=\"outline\">\n <Link href={next}>الصفحة التالية</Link>\n </Button>\n </div>\n )}\n </section>\n </>\n )\n}\nUsersIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/users/show.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { ArrowRight, KeyRound, UserCog, UserMinus, UserCheck, X } from 'lucide-react'\nimport type { OrgUnitNode, RoleSummary, UserSummary } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader, useDateTimeFormatter } from '~/components/admin-nav'\nimport { useConfirmAction } from '~/components/ui/confirm-action'\nimport { Button } from '~/components/ui/button'\nimport { Badge } from '~/components/ui/badge'\nimport { Label } from '~/components/ui/label'\nimport { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/card'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { user: UserSummary; roles: RoleSummary[]; orgUnits: OrgUnitNode[] }\nconst select = 'h-10 w-full rounded-md border border-input bg-white px-3 text-sm'\n\nexport default function UserShow({ user, roles, orgUnits }: Props) {\n const formatDateTime = useDateTimeFormatter()\n const { confirm, confirmation } = useConfirmAction()\n const [roleId, setRoleId] = useState(roles[0] ? String(roles[0].id) : '')\n const [roleUnit, setRoleUnit] = useState('')\n const [unitId, setUnitId] = useState('')\n const base = `/admin/users/${user.id}`\n const post = (url: string, data: Record<string, string | number | null> = {}) =>\n router.post(url, data, { preserveScroll: true })\n const assignRole = (event: FormEvent) => {\n event.preventDefault()\n confirm({\n title: 'إسناد الدور؟',\n description: 'ستتغير الصلاحيات المتاحة لهذا المستخدم فورًا.',\n action: () => post(`${base}/roles`, { roleId, orgUnitId: roleUnit || null }),\n })\n }\n const assignUnit = (event: FormEvent) => {\n event.preventDefault()\n if (unitId) post(`${base}/org-units`, { orgUnitId: unitId })\n }\n const available = orgUnits.filter((unit) => !user.orgUnits.some((own) => own.id === unit.id))\n return (\n <>\n <Head title={user.fullName || user.email} />\n <Link\n href=\"/admin/users\"\n className=\"mb-6 inline-flex items-center gap-2 text-xs text-muted-foreground\"\n >\n <ArrowRight size={15} />\n العودة إلى المستخدمين\n </Link>\n <AdminHeader title={user.fullName || user.email} description={user.email}>\n {user.disabledAt ? (\n <Button variant=\"outline\" onClick={() => post(`${base}/enable`)}>\n <UserCheck size={16} />\n تفعيل الحساب\n </Button>\n ) : (\n <Button\n variant=\"destructive\"\n onClick={() =>\n confirm({\n title: 'تعطيل الحساب؟',\n description: 'سيفقد المستخدم الوصول إلى النظام. لا يمكن تعطيل آخر مدير نشط.',\n destructive: true,\n action: () => post(`${base}/disable`),\n })\n }\n >\n <UserMinus size={16} />\n تعطيل الحساب\n </Button>\n )}\n <Button\n variant=\"outline\"\n onClick={() =>\n confirm({\n title: 'إنهاء جميع الجلسات؟',\n description: 'سيُطلب من هذا المستخدم تسجيل الدخول مجددًا.',\n destructive: true,\n action: () => post(`${base}/revoke-sessions`),\n })\n }\n >\n <KeyRound size={16} />\n إنهاء الجلسات\n </Button>\n <Button\n variant=\"outline\"\n disabled={Boolean(user.disabledAt)}\n onClick={() => post(`${base}/impersonate`)}\n >\n <UserCog size={16} />\n انتحال الحساب\n </Button>\n </AdminHeader>\n <div className=\"mb-6 flex flex-wrap items-center gap-3 text-sm\">\n {user.disabledAt ? (\n <Badge variant=\"destructive\">معطّل منذ {formatDateTime(user.disabledAt)}</Badge>\n ) : (\n <Badge>نشط</Badge>\n )}\n </div>\n <div className=\"grid gap-6 lg:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>الأدوار</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الدور</TableHead>\n <TableHead>الوحدة</TableHead>\n <TableHead className=\"w-16\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {user.roles.map((assignment) => (\n <TableRow key={assignment.id}>\n <TableCell>{assignment.role}</TableCell>\n <TableCell className=\"text-muted-foreground\">\n {assignment.orgUnit ?? 'كل الجهة'}\n </TableCell>\n <TableCell>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إزالة الدور ${assignment.role}`}\n onClick={() =>\n confirm({\n title: 'إزالة الدور؟',\n description: `سيُسحب دور ${assignment.role} من هذا المستخدم. لا يمكن سحب الإدارة من حسابك الحالي أو إزالة آخر مدير نشط.`,\n destructive: true,\n action: () =>\n router.delete(`${base}/roles/${assignment.id}`, {\n preserveScroll: true,\n }),\n })\n }\n >\n <X size={15} />\n </Button>\n </TableCell>\n </TableRow>\n ))}\n {user.roles.length === 0 && (\n <TableRow>\n <TableCell colSpan={3} className=\"text-center text-muted-foreground\">\n لا أدوار مسندة.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n <form onSubmit={assignRole} className=\"grid gap-3 md:grid-cols-[1fr_1fr_auto]\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"assign-role\">الدور</Label>\n <select\n id=\"assign-role\"\n className={select}\n value={roleId}\n onChange={(event) => setRoleId(event.target.value)}\n required\n >\n {roles.map((role) => (\n <option key={role.id} value={role.id}>\n {role.name}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"assign-role-unit\">مقيّد بوحدة (اختياري)</Label>\n <select\n id=\"assign-role-unit\"\n className={select}\n value={roleUnit}\n onChange={(event) => setRoleUnit(event.target.value)}\n >\n <option value=\"\">كل الجهة</option>\n {orgUnits.map((unit) => (\n <option key={unit.id} value={unit.id}>\n {'· '.repeat(unit.depth - 1)}\n {unit.name}\n </option>\n ))}\n </select>\n </div>\n <Button type=\"submit\" className=\"self-end\" disabled={!roleId}>\n إسناد الدور\n </Button>\n </form>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>الوحدات التنظيمية</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <ul className=\"space-y-2\">\n {user.orgUnits.map((unit) => (\n <li\n key={unit.id}\n className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2 text-sm\"\n >\n <span>\n {unit.name}\n <span className=\"ms-2 text-xs text-muted-foreground\" dir=\"ltr\">\n {unit.path}\n </span>\n </span>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إزالة من ${unit.name}`}\n onClick={() =>\n router.delete(`${base}/org-units/${unit.id}`, { preserveScroll: true })\n }\n >\n <X size={15} />\n </Button>\n </li>\n ))}\n {user.orgUnits.length === 0 && (\n <li className=\"text-sm text-muted-foreground\">\n لا عضويات؛ الكيانات المقيدة بالنطاق مخفية عن هذا المستخدم.\n </li>\n )}\n </ul>\n <form onSubmit={assignUnit} className=\"grid gap-3 md:grid-cols-[1fr_auto]\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"assign-unit\">الوحدة</Label>\n <select\n id=\"assign-unit\"\n className={select}\n value={unitId}\n onChange={(event) => setUnitId(event.target.value)}\n >\n <option value=\"\">اختر وحدة</option>\n {available.map((unit) => (\n <option key={unit.id} value={unit.id}>\n {'· '.repeat(unit.depth - 1)}\n {unit.name}\n </option>\n ))}\n </select>\n </div>\n <Button type=\"submit\" className=\"self-end\" disabled={!unitId}>\n إضافة إلى الوحدة\n </Button>\n </form>\n </CardContent>\n </Card>\n </div>\n {confirmation}\n </>\n )\n}\nUserShow.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/auth/forgot.tsx":"import { Head } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\n\nexport default function Forgot() {\n return (\n <>\n <Head title=\"استعادة كلمة المرور\" />\n <div dir=\"rtl\" className=\"mx-auto w-full max-w-md py-10\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">استعادة كلمة المرور</CardTitle>\n <CardDescription>\n أدخل بريدك الإلكتروني وسنرسل إليك رابطاً صالحاً لساعة واحدة لاختيار كلمة مرور\n جديدة.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <Form route=\"password_reset.send\" className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input\n type=\"email\"\n name=\"email\"\n id=\"email\"\n dir=\"ltr\"\n autoComplete=\"username\"\n required\n aria-invalid={errors.email ? true : undefined}\n />\n {errors.email && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.email}\n </p>\n )}\n </div>\n <Button type=\"submit\" className=\"w-full\" disabled={processing}>\n إرسال رابط إعادة التعيين\n </Button>\n </>\n )}\n </Form>\n <p className=\"text-center text-sm text-muted-foreground\">\n تذكرت كلمة المرور؟{' '}\n <Link route=\"session.create\" className=\"font-medium text-primary\">\n العودة إلى تسجيل الدخول\n </Link>\n </p>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\n","inertia/pages/auth/invitation.tsx":"import { Head, useForm } from '@inertiajs/react'\nimport { ResourceSurface } from '~/components/ui/resource-surface'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\n\nexport default function Invitation({ token, valid }: { token: string; valid: boolean }) {\n const form = useForm({ password: '', passwordConfirmation: '', form: '' })\n return (\n <>\n <Head title=\"قبول الدعوة\" />\n <ResourceSurface\n title=\"مرحبًا بك — أنشئ حسابك\"\n description=\"اختر كلمة مرور خاصة بك لقبول الدعوة. يعيّن المسؤول صلاحيات العمل لحسابك.\"\n backHref=\"/login\"\n mode=\"edit\"\n >\n {valid ? (\n <form\n className=\"space-y-5\"\n onSubmit={(event) => {\n event.preventDefault()\n form.post(`/invitations/${encodeURIComponent(token)}`)\n }}\n >\n {form.errors.form && (\n <p role=\"alert\" className=\"text-destructive\">\n {form.errors.form}\n </p>\n )}\n <div className=\"space-y-2\">\n <Label htmlFor=\"password\">كلمة المرور</Label>\n <Input\n id=\"password\"\n type=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n minLength={8}\n maxLength={32}\n value={form.data.password}\n onChange={(e) => form.setData('password', e.target.value)}\n aria-invalid={Boolean(form.errors.password)}\n />\n {form.errors.password && <p role=\"alert\">{form.errors.password}</p>}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"passwordConfirmation\">تأكيد كلمة المرور</Label>\n <Input\n id=\"passwordConfirmation\"\n type=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n value={form.data.passwordConfirmation}\n onChange={(e) => form.setData('passwordConfirmation', e.target.value)}\n aria-invalid={Boolean(form.errors.passwordConfirmation)}\n />\n {form.errors.passwordConfirmation && (\n <p role=\"alert\">{form.errors.passwordConfirmation}</p>\n )}\n </div>\n <Button type=\"submit\" disabled={form.processing}>\n {form.processing ? 'جارٍ إنشاء الحساب…' : 'إنشاء حسابي'}\n </Button>\n </form>\n ) : (\n <p role=\"alert\">\n الدعوة غير صالحة أو انتهت صلاحيتها أو استُخدمت. اطلب دعوة جديدة من المسؤول.\n </p>\n )}\n </ResourceSurface>\n </>\n )\n}\n","inertia/pages/auth/login.tsx":"import { Head } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\n\ntype Props = { socialProviders: { name: string; label: string }[] }\n\nexport default function Login({ socialProviders }: Props) {\n return (\n <>\n <Head title=\"تسجيل الدخول\" />\n <div dir=\"rtl\" className=\"mx-auto w-full max-w-md py-10\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">تسجيل الدخول</CardTitle>\n <CardDescription>أدخل بيانات حسابك للمتابعة إلى مساحة العمل</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <Form route=\"session.store\" className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input\n type=\"email\"\n name=\"email\"\n id=\"email\"\n dir=\"ltr\"\n autoComplete=\"username\"\n required\n aria-invalid={errors.email ? true : undefined}\n />\n {errors.email && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.email}\n </p>\n )}\n </div>\n\n <div className=\"space-y-2\">\n <div className=\"flex items-center justify-between\">\n <Label htmlFor=\"password\">كلمة المرور</Label>\n <Link\n route=\"password_reset.forgot\"\n className=\"text-xs text-muted-foreground hover:text-foreground\"\n >\n نسيت كلمة المرور؟\n </Link>\n </div>\n <Input\n type=\"password\"\n name=\"password\"\n id=\"password\"\n dir=\"ltr\"\n autoComplete=\"current-password\"\n required\n aria-invalid={errors.password ? true : undefined}\n />\n {errors.password && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.password}\n </p>\n )}\n </div>\n\n <Button type=\"submit\" className=\"w-full\" disabled={processing}>\n دخول\n </Button>\n </>\n )}\n </Form>\n\n {socialProviders.length > 0 && (\n <div className=\"space-y-3\" aria-label=\"الدخول عبر مزوّد خارجي\">\n <p className=\"text-center text-xs text-muted-foreground\">أو تابع عبر</p>\n {socialProviders.map((provider) => (\n <Button key={provider.name} asChild variant=\"outline\" className=\"w-full\">\n {/* A full navigation: the provider redirect must leave the Inertia app. */}\n <a href={`/oauth/${provider.name}/redirect`}>الدخول عبر {provider.label}</a>\n </Button>\n ))}\n </div>\n )}\n\n <p className=\"text-center text-sm text-muted-foreground\">\n ليس لديك حساب؟{' '}\n <Link route=\"new_account.create\" className=\"font-medium text-primary\">\n إنشاء حساب\n </Link>\n </p>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\n","inertia/pages/auth/reset.tsx":"import { Head } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\n\ntype Props = { token: string; valid: boolean }\n\nexport default function Reset({ token, valid }: Props) {\n return (\n <>\n <Head title=\"كلمة مرور جديدة\" />\n <div dir=\"rtl\" className=\"mx-auto w-full max-w-md py-10\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">كلمة مرور جديدة</CardTitle>\n <CardDescription>اختر كلمة مرور جديدة لحسابك. ستُنهى جلساتك السابقة كلها.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n {valid ? (\n <Form route=\"password_reset.update\" routeParams={{ token }} className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"password\">كلمة المرور الجديدة</Label>\n <Input\n type=\"password\"\n name=\"password\"\n id=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.password ? true : undefined}\n />\n {errors.password && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.password}\n </p>\n )}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"passwordConfirmation\">تأكيد كلمة المرور</Label>\n <Input\n type=\"password\"\n name=\"passwordConfirmation\"\n id=\"passwordConfirmation\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.passwordConfirmation ? true : undefined}\n />\n {errors.passwordConfirmation && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.passwordConfirmation}\n </p>\n )}\n </div>\n <Button type=\"submit\" className=\"w-full\" disabled={processing}>\n حفظ كلمة المرور\n </Button>\n </>\n )}\n </Form>\n ) : (\n <Alert variant=\"destructive\">\n <TriangleAlert />\n <AlertTitle>الرابط غير صالح</AlertTitle>\n <AlertDescription>\n رابط إعادة التعيين غير صالح أو انتهت صلاحيته أو استُخدم من قبل. اطلب رابطاً\n جديداً.\n </AlertDescription>\n </Alert>\n )}\n <p className=\"text-center text-sm text-muted-foreground\">\n <Link route=\"password_reset.forgot\" className=\"font-medium text-primary\">\n طلب رابط جديد\n </Link>\n </p>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\n","inertia/pages/auth/signup.tsx":"import { Head } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\n\nexport default function Signup() {\n return (\n <>\n <Head title=\"إنشاء حساب\" />\n <div dir=\"rtl\" className=\"mx-auto w-full max-w-md py-10\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">إنشاء حساب</CardTitle>\n <CardDescription>\n أدخل بياناتك. يمنحك مدير النظام صلاحيات العمل بعد إنشاء الحساب.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <Form route=\"new_account.store\" className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"fullName\">الاسم الكامل</Label>\n <Input\n type=\"text\"\n name=\"fullName\"\n id=\"fullName\"\n autoComplete=\"name\"\n aria-invalid={errors.fullName ? true : undefined}\n />\n {errors.fullName && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.fullName}\n </p>\n )}\n </div>\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input\n type=\"email\"\n name=\"email\"\n id=\"email\"\n dir=\"ltr\"\n autoComplete=\"email\"\n required\n aria-invalid={errors.email ? true : undefined}\n />\n {errors.email && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.email}\n </p>\n )}\n </div>\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"password\">كلمة المرور</Label>\n <Input\n type=\"password\"\n name=\"password\"\n id=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.password ? true : undefined}\n />\n {errors.password && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.password}\n </p>\n )}\n </div>\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"passwordConfirmation\">تأكيد كلمة المرور</Label>\n <Input\n type=\"password\"\n name=\"passwordConfirmation\"\n id=\"passwordConfirmation\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.passwordConfirmation ? true : undefined}\n />\n {errors.passwordConfirmation && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.passwordConfirmation}\n </p>\n )}\n </div>\n\n <Button type=\"submit\" className=\"w-full\" disabled={processing}>\n إنشاء الحساب\n </Button>\n </>\n )}\n </Form>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n لديك حساب بالفعل؟{' '}\n <Link route=\"session.create\" className=\"font-medium text-primary\">\n تسجيل الدخول\n </Link>\n </p>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\n","inertia/pages/errors/not_found.tsx":"export default function NotFound() {\n return (\n <>\n <h1>Page not found</h1>\n </>\n )\n}\n","inertia/pages/errors/server_error.tsx":"export default function ServerError() {\n return (\n <>\n <h1>Something went wrong</h1>\n </>\n )\n}\n","inertia/pages/home.tsx":"import { Head, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport type { ResourceNavigation } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\nimport Workspace from '~/layouts/workspace'\nimport Layout from '~/layouts/default'\n\nexport default function Home() {\n const { user, isAdmin, navigation } = usePage<{\n user?: { email: string }\n isAdmin: boolean\n navigation: ResourceNavigation\n }>().props\n return (\n <>\n <Head title=\"مساحة العمل · adula kit\" />\n <div className=\"mx-auto max-w-5xl space-y-6 px-6 py-12\" dir=\"rtl\">\n <Card>\n <CardHeader>\n <CardTitle>\n <h1 className=\"text-3xl\">مساحة العمل</h1>\n </CardTitle>\n <CardDescription>وحدات أعمالك وإعدادات حسابك في مكان واحد.</CardDescription>\n </CardHeader>\n <CardContent className=\"flex flex-wrap gap-3\">\n {user ? (\n <>\n <Button asChild>\n <Link href=\"/account/profile\">حسابي</Link>\n </Button>\n <Button asChild variant=\"outline\">\n <Link href=\"/notifications\">الإشعارات</Link>\n </Button>\n {isAdmin && (\n <Button asChild variant=\"outline\">\n <Link href=\"/admin/setup\">بدء الإعداد الأولي</Link>\n </Button>\n )}\n {isAdmin && (\n <Button asChild variant=\"outline\">\n <Link href=\"/admin/users\">إدارة النظام</Link>\n </Button>\n )}\n </>\n ) : (\n <Button asChild>\n <Link href=\"/login\">تسجيل الدخول</Link>\n </Button>\n )}\n </CardContent>\n </Card>\n {(navigation ?? []).length ? (\n <div className=\"grid gap-4 sm:grid-cols-2\">\n {navigation.map((entry) => (\n <Card key={entry.href}>\n <CardHeader>\n <CardTitle>{entry.label}</CardTitle>\n </CardHeader>\n <CardContent>\n <Button asChild variant=\"outline\">\n <Link href={entry.href}>فتح {entry.label}</Link>\n </Button>\n </CardContent>\n </Card>\n ))}\n </div>\n ) : (\n <p className=\"text-sm text-muted-foreground\">ستظهر هنا وحدات العمل المتاحة لحسابك.</p>\n )}\n </div>\n </>\n )\n}\n\nHome.layout = (props: { user?: unknown }) => (props.user ? Workspace : Layout)\n","inertia/pages/resources/index.tsx":"import { Head } from '@inertiajs/react'\nimport type { SerializedRecord } from '@adula/kit/types'\ntype Props = {\n label: string\n name: string\n result: { data: SerializedRecord[]; meta: { nextCursor: string | null; limit: number } }\n}\nexport default function ResourceIndex({ label, name, result }: Props) {\n return (\n <main dir=\"rtl\" style={{ maxWidth: 1000, margin: '60px auto', padding: 24 }}>\n <Head title={label} />\n <a href=\"/\">العودة للرئيسية</a>\n <h1>{label}</h1>\n <p>واجهة فحص النواة · واجهة إدارة الموارد الكاملة ضمن المرحلة الثانية.</p>\n {result.data.length ? (\n <table style={{ width: '100%', borderCollapse: 'collapse' }}>\n <thead>\n <tr>\n {Object.keys(result.data[0]).map((key) => (\n <th key={key} style={{ textAlign: 'right', padding: 12 }}>\n {key}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {result.data.map((row) => (\n <tr key={String(row.id)}>\n {Object.keys(result.data[0]).map((key) => (\n <td key={key} style={{ padding: 12, borderTop: '1px solid #ddd' }}>\n {String(row[key] ?? '—')}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n ) : (\n <p>لا توجد سجلات متاحة ضمن صلاحياتك.</p>\n )}\n {result.meta.nextCursor && (\n <a href={`/resources/${name}?cursor=${encodeURIComponent(result.meta.nextCursor)}`}>\n الصفحة التالية ←\n </a>\n )}\n </main>\n )\n}\n","inertia/pages/resources/page.tsx":"import type { ReactElement } from 'react'\nimport { ResourcePage, type ResourcePageProps } from '~/components/ui/resource-page'\nimport Workspace from '~/layouts/workspace'\n\n// Inertia's page discovery applies Omit to top-level props; the discriminated union stays under `view`.\nexport default function Page(props: ResourcePageProps) {\n return <ResourcePage {...props} />\n}\nPage.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/users/invite.tsx":"import type { ReactElement } from 'react'\nimport { Head, useForm, usePage } from '@inertiajs/react'\nimport Workspace from '~/layouts/workspace'\nimport { ResourceSurface } from '~/components/ui/resource-surface'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\n\nexport default function Invite() {\n const page = usePage<{ isAdmin?: boolean }>()\n const form = useForm({ fullName: '', email: '', form: '' })\n return (\n <>\n <Head title=\"دعوة مستخدم\" />\n <ResourceSurface\n title=\"إضافة مستخدم بدعوة بريدية\"\n description=\"يختار المدعو كلمة مروره عبر رابط صالح لمدة 24 ساعة. يعيّن المدير الأدوار بعد قبول الدعوة.\"\n backHref={page.props.isAdmin ? '/admin/users' : '/'}\n mode=\"edit\"\n >\n <form\n className=\"space-y-5\"\n onSubmit={(event) => {\n event.preventDefault()\n form.post('/users/invite', { onSuccess: () => form.reset() })\n }}\n >\n {typeof page.flash.success === 'string' && (\n <p role=\"status\" data-flash-message={page.flash.success}>\n {page.flash.success}\n </p>\n )}\n {form.errors.form && (\n <p role=\"alert\" className=\"text-destructive\">\n {form.errors.form}\n </p>\n )}\n <div className=\"space-y-2\">\n <Label htmlFor=\"fullName\">الاسم الكامل</Label>\n <Input\n id=\"fullName\"\n required\n maxLength={120}\n value={form.data.fullName}\n onChange={(e) => form.setData('fullName', e.target.value)}\n aria-invalid={Boolean(form.errors.fullName)}\n />\n {form.errors.fullName && <p role=\"alert\">{form.errors.fullName}</p>}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input\n id=\"email\"\n type=\"email\"\n dir=\"ltr\"\n required\n maxLength={254}\n value={form.data.email}\n onChange={(e) => form.setData('email', e.target.value)}\n aria-invalid={Boolean(form.errors.email)}\n />\n {form.errors.email && <p role=\"alert\">{form.errors.email}</p>}\n </div>\n <p className=\"text-sm text-muted-foreground\">\n لإعادة إرسال دعوة لم تُقبل، أدخل البريد نفسه بعد دقيقة. يصبح الرابط السابق غير صالح.\n </p>\n <Button type=\"submit\" disabled={form.processing}>\n {form.processing ? 'جارٍ الإرسال…' : 'إرسال الدعوة'}\n </Button>\n </form>\n </ResourceSurface>\n </>\n )\n}\nInvite.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/ssr.tsx":"import { client } from '~/client'\nimport { type ReactElement } from 'react'\nimport Layout from '~/layouts/default'\nimport { type Data } from '@generated/data'\nimport ReactDOMServer from 'react-dom/server'\nimport { createInertiaApp, type ResolvedComponent } from '@inertiajs/react'\nimport { TuyauProvider } from '@adonisjs/inertia/react'\nimport { resolvePageComponent } from '@adonisjs/inertia/helpers'\n\nexport default function render(page: any) {\n return createInertiaApp({\n page,\n render: ReactDOMServer.renderToString,\n resolve: (name) => {\n return resolvePageComponent<ResolvedComponent>(\n `./pages/${name}.tsx`,\n import.meta.glob<ResolvedComponent>('./pages/**/*.tsx', { eager: true }),\n (resolvedPage: ReactElement<Data.SharedProps>) => <Layout children={resolvedPage} />\n )\n },\n setup: ({ App, props }) => {\n return (\n <TuyauProvider client={client}>\n <App {...props} />\n </TuyauProvider>\n )\n },\n })\n}\n","inertia/tsconfig.json":"{\n \"extends\": \"@adonisjs/tsconfig/tsconfig.client.json\",\n \"compilerOptions\": {\n \"module\": \"ESNext\",\n \"jsx\": \"react-jsx\",\n \"paths\": {\n \"~/*\": [\"./*\"],\n \"@generated/*\": [\"../.adonisjs/client/*\"]\n }\n },\n \"include\": [\n \"./**/*.ts\",\n \"./**/*.tsx\",\n \"../.adonisjs/client/**/*.ts\",\n \"../.adonisjs/server/**/*.ts\"\n ]\n}\n","inertia/types.ts":"import { type Data } from '@generated/data'\nimport { type PropsWithChildren } from 'react'\nimport { type JSONDataTypes } from '@adonisjs/core/types/transformers'\n\nexport type InertiaProps<T extends JSONDataTypes = {}> = PropsWithChildren<Data.SharedProps & T>\n\n/**\n * Bridges the server side types into the Inertia client. \"usePage().props\" is\n * typed from the Inertia middleware share method and \"usePage().flash\" from\n * its flash method.\n */\ndeclare module '@inertiajs/core' {\n interface InertiaConfig {\n sharedPageProps: Data.SharedProps\n flashDataType: Data.FlashMessages\n }\n}\n","providers/api_provider.ts":"import { HttpContext } from '@adonisjs/core/http'\nimport { BaseSerializer } from '@adonisjs/core/transformers'\nimport { type SimplePaginatorMetaKeys } from '@adonisjs/lucid/types/querybuilder'\n\n/**\n * Custom serializer for API responses that ensures consistent JSON structure\n * across all API endpoints. Wraps response data in a 'data' property and handles\n * pagination metadata for Lucid ORM query results.\n */\nclass ApiSerializer extends BaseSerializer<{\n Wrap: 'data'\n PaginationMetaData: SimplePaginatorMetaKeys\n}> {\n /**\n * Wraps all serialized data under this key in the response object.\n * Example: { data: [...] } instead of returning raw arrays/objects\n */\n wrap: 'data' = 'data'\n\n /**\n * Validates and defines pagination metadata structure for paginated responses.\n * Ensures that pagination info from Lucid queries is properly formatted.\n *\n * @throws Error if metadata doesn't match Lucid's pagination structure\n */\n definePaginationMetaData(metaData: unknown): SimplePaginatorMetaKeys {\n if (!this.isLucidPaginatorMetaData(metaData)) {\n throw new Error(\n 'Invalid pagination metadata. Expected metadata to contain Lucid pagination keys'\n )\n }\n return metaData\n }\n}\n\n/**\n * Single instance of ApiSerializer used across the application\n */\nconst serializer = new ApiSerializer()\nconst serialize = Object.assign(\n function (this: HttpContext, ...[data, resolver]: Parameters<ApiSerializer['serialize']>) {\n return serializer.serialize(data, resolver ?? this.containerResolver)\n },\n {\n withoutWrapping(\n this: HttpContext,\n ...[data, resolver]: Parameters<ApiSerializer['serializeWithoutWrapping']>\n ) {\n return serializer.serializeWithoutWrapping(data, resolver ?? this.containerResolver)\n },\n }\n) as ApiSerializer['serialize'] & { withoutWrapping: ApiSerializer['serializeWithoutWrapping'] }\n\n/**\n * Adds the serialize method to all HttpContext instances.\n * Usage in controllers: return ctx.serialize(data)\n * This ensures all API responses follow the same structure with data wrapping.\n */\nHttpContext.instanceProperty('serialize', serialize)\n\n/**\n * Module augmentation to add the serialize method to HttpContext.\n * This allows controllers to use ctx.serialize() for consistent API responses.\n */\ndeclare module '@adonisjs/core/http' {\n export interface HttpContext {\n serialize: typeof serialize\n }\n}\n","resources/views/inertia_layout.edge":"<!DOCTYPE html>\n<html lang=\"ar\" dir=\"rtl\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title data-inertia>\n adula kit\n </title>\n\n @viteReactRefresh()\n @vite(['inertia/app.tsx'])\n @inertiaHead()\n @stack('dumper')\n </head>\n\n <body>\n @inertia()\n </body>\n\n</html>\n","start/env.ts":"/*\n|--------------------------------------------------------------------------\n| Environment variables service\n|--------------------------------------------------------------------------\n|\n| The `Env.create` method creates an instance of the Env service. The\n| service validates the environment variables and also cast values\n| to JavaScript data types.\n|\n*/\n\nimport { Env } from '@adonisjs/core/env'\n\nconst env = await Env.create(new URL('../', import.meta.url), {\n // Node\n NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),\n PORT: Env.schema.number(),\n HOST: Env.schema.string({ format: 'host' }),\n LOG_LEVEL: Env.schema.string(),\n\n // App\n ADULA_NAMESPACE: Env.schema.string(),\n APP_KEY: Env.schema.secret(),\n APP_URL: Env.schema.string({ format: 'url', tld: false }),\n\n // Session\n SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),\n DB_HOST: Env.schema.string(),\n DB_PORT: Env.schema.number(),\n DB_USER: Env.schema.string(),\n DB_PASSWORD: Env.schema.string(),\n DB_DATABASE: Env.schema.string(),\n REDIS_HOST: Env.schema.string(),\n REDIS_PORT: Env.schema.number(),\n REDIS_PASSWORD: Env.schema.string.optional(),\n DRIVE_DISK: Env.schema.enum(['local', 's3'] as const),\n BACKUP_S3_ENDPOINT: Env.schema.string.optional(),\n BACKUP_S3_BUCKET: Env.schema.string.optional(),\n BACKUP_S3_PREFIX: Env.schema.string.optional(),\n BACKUP_S3_REGION: Env.schema.string.optional(),\n BACKUP_S3_ACCESS_KEY_ID: Env.schema.string.optional(),\n BACKUP_S3_SECRET_ACCESS_KEY: Env.schema.string.optional(),\n\n /*\n |----------------------------------------------------------\n | Variables for configuring the limiter package\n |----------------------------------------------------------\n */\n LIMITER_STORE: Env.schema.enum.optional(['redis', 'memory'] as const),\n\n /*\n |----------------------------------------------------------\n | Variables for configuring the mail package\n |----------------------------------------------------------\n */\n MAIL_MAILER: Env.schema.enum.optional(['smtp'] as const),\n MAIL_FROM_NAME: Env.schema.string.optional(),\n MAIL_FROM_ADDRESS: Env.schema.string.optional(),\n SMTP_HOST: Env.schema.string.optional(),\n SMTP_PORT: Env.schema.number.optional(),\n SMTP_SECURE: Env.schema.boolean.optional(),\n SMTP_REQUIRE_TLS: Env.schema.boolean.optional(),\n SMTP_USERNAME: Env.schema.string.optional(),\n SMTP_PASSWORD: Env.schema.string.optional(),\n\n /*\n |----------------------------------------------------------\n | Variables for configuring ally package (a provider is\n | offered only when both of its values are present)\n |----------------------------------------------------------\n */\n GITHUB_CLIENT_ID: Env.schema.string.optional(),\n GITHUB_CLIENT_SECRET: Env.schema.string.optional(),\n GOOGLE_CLIENT_ID: Env.schema.string.optional(),\n GOOGLE_CLIENT_SECRET: Env.schema.string.optional(),\n\n /*\n |----------------------------------------------------------\n | Variables for the s3 drive disk (DRIVE_DISK=s3)\n |----------------------------------------------------------\n */\n AWS_ACCESS_KEY_ID: Env.schema.string.optional(),\n AWS_SECRET_ACCESS_KEY: Env.schema.string.optional(),\n AWS_REGION: Env.schema.string.optional(),\n AWS_ENDPOINT: Env.schema.string.optional(),\n S3_BUCKET: Env.schema.string.optional(),\n})\n\nif (env.get('NODE_ENV') === 'production') {\n for (const key of [\n 'BACKUP_S3_ENDPOINT',\n 'BACKUP_S3_BUCKET',\n 'BACKUP_S3_REGION',\n 'BACKUP_S3_ACCESS_KEY_ID',\n 'BACKUP_S3_SECRET_ACCESS_KEY',\n ] as const) {\n if (!env.get(key)) throw new Error(`${key} is required in production`)\n }\n if (env.get('SESSION_DRIVER') !== 'database')\n throw new Error('Production requires revocable database sessions')\n if (env.get('DRIVE_DISK') === 's3')\n for (const key of [\n 'AWS_ACCESS_KEY_ID',\n 'AWS_SECRET_ACCESS_KEY',\n 'AWS_REGION',\n 'S3_BUCKET',\n ] as const)\n if (!env.get(key)) throw new Error(`${key} is required when DRIVE_DISK=s3`)\n}\nexport default env\n","start/kernel.ts":"/*\n|--------------------------------------------------------------------------\n| HTTP kernel file\n|--------------------------------------------------------------------------\n|\n| The HTTP kernel file is used to register the middleware with the server\n| or the router.\n|\n*/\n\nimport router from '@adonisjs/core/services/router'\nimport server from '@adonisjs/core/services/server'\n\n/**\n * The error handler is used to convert an exception\n * to a HTTP response.\n */\nserver.errorHandler(() => import('#exceptions/handler'))\n\n/**\n * The server middleware stack runs middleware on all the HTTP\n * requests, even if there is no route registered for\n * the request URL.\n */\nserver.use([\n () => import('#middleware/container_bindings_middleware'),\n () => import('@adonisjs/static/static_middleware'),\n () => import('@adonisjs/cors/cors_middleware'),\n () => import('@adonisjs/vite/vite_middleware'),\n () => import('#middleware/inertia_middleware'),\n])\n\n/**\n * The router middleware stack runs middleware on all the HTTP\n * requests with a registered route.\n */\nrouter.use([\n () => import('@adonisjs/core/bodyparser_middleware'),\n () => import('@adonisjs/session/session_middleware'),\n () => import('@adonisjs/shield/shield_middleware'),\n () => import('@adonisjs/auth/initialize_auth_middleware'),\n () => import('#middleware/silent_auth_middleware'),\n])\n\n/**\n * Named middleware collection must be explicitly assigned to\n * the routes or the routes group.\n */\nexport const middleware = router.named({\n guest: () => import('#middleware/guest_middleware'),\n auth: () => import('#middleware/auth_middleware'),\n mcp: () => import('#middleware/mcp_middleware'),\n admin: () => import('#middleware/admin_middleware'),\n})\n","start/limiter.ts":"/*\n|--------------------------------------------------------------------------\n| Define HTTP limiters\n|--------------------------------------------------------------------------\n|\n| The \"limiter.define\" method creates an HTTP middleware to apply rate\n| limits on a route or a group of routes. Authentication endpoints are\n| keyed by IP (login also by e-mail); resource and MCP endpoints by user.\n|\n*/\n\nimport limiter from '@adonisjs/limiter/services/main'\nimport type { HttpContext } from '@adonisjs/core/http'\n\nconst message = 'محاولات كثيرة في وقت قصير. انتظر دقيقة ثم حاول مجدداً.'\nconst withMessage = (error: { setMessage(value: string): unknown }) => {\n error.setMessage(message)\n}\nconst emailOf = (ctx: HttpContext) =>\n String(ctx.request.input('email', '') ?? '')\n .trim()\n .toLowerCase()\n\n/** Login: 5 attempts per minute for one e-mail from one address. */\nexport const loginThrottle = limiter.define('login', (ctx) =>\n limiter\n .allowRequests(5)\n .every('1 minute')\n .usingKey(`${ctx.request.ip()}:${emailOf(ctx)}`)\n .limitExceeded(withMessage)\n)\n\n/** Signup: 3 accounts per minute per address. */\nexport const signupThrottle = limiter.define('signup', (ctx) =>\n limiter.allowRequests(3).every('1 minute').usingKey(ctx.request.ip()).limitExceeded(withMessage)\n)\n\n/** Recovery (forgot + reset): 3 submissions per minute per address. */\nexport const passwordThrottle = limiter.define('password', (ctx) =>\n limiter.allowRequests(3).every('1 minute').usingKey(ctx.request.ip()).limitExceeded(withMessage)\n)\n\n/** OAuth redirects and callbacks: 10 per minute per address. */\nexport const oauthThrottle = limiter.define('oauth', (ctx) =>\n limiter.allowRequests(10).every('1 minute').usingKey(ctx.request.ip()).limitExceeded(withMessage)\n)\n\n/** Resource and MCP routes: 300 requests per minute per authenticated user. */\nexport const apiThrottle = limiter.define('api', (ctx) =>\n limiter\n .allowRequests(300)\n .every('1 minute')\n .usingKey(ctx.auth.user ? `user:${ctx.auth.user.id}` : `ip:${ctx.request.ip()}`)\n .limitExceeded(withMessage)\n)\n","start/listeners.ts":"import type { Listener } from '@adula/kit'\nexport const listeners: Listener[] = []\n","start/modules.ts":"import { ResourceRegistry, type Module } from '@adula/kit'\n// adula:imports\nexport const modules: Module[] = [\n /* adula:modules */\n]\nexport const registry = new ResourceRegistry().register(modules)\n","start/routes.ts":"/*\n|--------------------------------------------------------------------------\n| Routes file\n|--------------------------------------------------------------------------\n|\n| The routes file is used for defining the HTTP routes.\n|\n*/\n\nimport { middleware } from '#start/kernel'\nimport { controllers } from '#generated/controllers'\nimport router from '@adonisjs/core/services/router'\nimport db from '@adonisjs/lucid/services/db'\nimport { Settings } from '@adula/kit'\nimport redis from '@adonisjs/redis/services/main'\nimport {\n apiThrottle,\n loginThrottle,\n oauthThrottle,\n passwordThrottle,\n signupThrottle,\n} from '#start/limiter'\nconst ResourcesController = () => import('#controllers/resources_controller')\nconst AttachmentsController = () => import('#controllers/attachments_controller')\nconst SavedViewsController = () => import('#controllers/saved_views_controller')\nconst PasswordResetController = () => import('#controllers/password_reset_controller')\nconst UserInvitationsController = () => import('#controllers/user_invitations_controller')\nconst OauthController = () => import('#controllers/oauth_controller')\nconst ProfileController = () => import('#controllers/profile_controller')\nconst AccountSessionsController = () => import('#controllers/account_sessions_controller')\nconst AdminSessionsController = () => import('#controllers/admin_sessions_controller')\nconst AdminUsersController = () => import('#controllers/admin/users_controller')\nconst AdminRolesController = () => import('#controllers/admin/roles_controller')\nconst AdminOrgUnitsController = () => import('#controllers/admin/org_units_controller')\nconst AdminActivityController = () => import('#controllers/admin/activity_controller')\nconst AdminJobsController = () => import('#controllers/admin/jobs_controller')\nconst AdminSettingsController = () => import('#controllers/admin/settings_controller')\nconst SetupController = () => import('#controllers/admin/setup_controller')\nconst NotificationsController = () => import('#controllers/admin/notifications_controller')\n\nrouter.on('/').renderInertia('home', {}).as('home')\n\nrouter.mcp().use([middleware.auth(), apiThrottle, middleware.mcp()])\n\nrouter.get('/health', async ({ response }) => {\n try {\n await db.rawQuery('SELECT 1')\n await Promise.race([\n redis.ping(),\n new Promise((_, reject) => {\n const timer = setTimeout(() => reject(new Error('Redis unavailable')), 2000)\n timer.unref()\n }),\n ])\n const lastOffsite = await new Settings(db.connection().getWriteClient()).get<string>(\n 'backup.lastOffsite'\n )\n return {\n status:\n lastOffsite && Date.now() - Date.parse(lastOffsite) < 48 * 3600000 ? 'ok' : 'degraded',\n database: 'ok',\n redis: 'ok',\n backup: { lastOffsite: lastOffsite ?? null },\n }\n } catch {\n return response.serviceUnavailable({ status: 'unhealthy', dependencies: 'unavailable' })\n }\n})\n\nrouter\n .group(() => {\n router.get('/resources/:resource', [ResourcesController, 'index'])\n router.get('/resources/:resource/create', [ResourcesController, 'create'])\n router.get('/resources/:resource/options/:field', [ResourcesController, 'options'])\n router.get('/resources/:resource/:id/edit', [ResourcesController, 'edit'])\n router.get('/resources/:resource/:id', [ResourcesController, 'show'])\n router.post('/resources/:resource', [ResourcesController, 'store'])\n router.patch('/resources/:resource/:id', [ResourcesController, 'update'])\n router.delete('/resources/:resource/:id', [ResourcesController, 'destroy'])\n router.post('/resources/:resource/:id/submit', [ResourcesController, 'submit'])\n router.post('/resources/:resource/:id/cancel', [ResourcesController, 'cancel'])\n router.post('/resources/:resource/views', [SavedViewsController, 'store'])\n router.delete('/resources/:resource/views/:id', [SavedViewsController, 'destroy'])\n router.post('/attachments', [AttachmentsController, 'store'])\n router.get('/attachments/:id', [AttachmentsController, 'show'])\n })\n .use([middleware.auth(), apiThrottle])\n\nrouter\n .group(() => {\n router.get('signup', [controllers.NewAccount, 'create'])\n router.get('invitations/:token', [UserInvitationsController, 'show'])\n router.post('invitations/:token', [UserInvitationsController, 'accept']).use(passwordThrottle)\n router.post('signup', [controllers.NewAccount, 'store']).use(signupThrottle)\n\n router.get('login', [controllers.Session, 'create'])\n router.post('login', [controllers.Session, 'store']).use(loginThrottle)\n\n router.get('password/forgot', [PasswordResetController, 'forgot'])\n router.post('password/forgot', [PasswordResetController, 'send']).use(passwordThrottle)\n router.get('password/reset/:token', [PasswordResetController, 'reset'])\n router.post('password/reset/:token', [PasswordResetController, 'update']).use(passwordThrottle)\n\n router.get('oauth/:provider/redirect', [OauthController, 'redirect']).use(oauthThrottle)\n router.get('oauth/:provider/callback', [OauthController, 'callback']).use(oauthThrottle)\n })\n .use(middleware.guest())\n\nrouter\n .group(() => {\n router.post('logout', [controllers.Session, 'destroy'])\n router.get('users/invite', [UserInvitationsController, 'create'])\n router.post('users/invite', [UserInvitationsController, 'store']).use(apiThrottle)\n\n router.get('account/profile', [ProfileController, 'show'])\n router.patch('account/profile', [ProfileController, 'update'])\n router.post('account/password', [ProfileController, 'password'])\n router.get('account/sessions', [AccountSessionsController, 'index'])\n router.delete('account/sessions', [AccountSessionsController, 'purge'])\n router.delete('account/sessions/:id', [AccountSessionsController, 'destroy'])\n\n router.get('admin/sessions', [AdminSessionsController, 'index'])\n router.delete('admin/sessions/:id', [AdminSessionsController, 'destroy'])\n })\n .use(middleware.auth())\n\nrouter\n .group(() => {\n router.get('notifications', [NotificationsController, 'index'])\n router.post('notifications/read-all', [NotificationsController, 'readAll'])\n router.post('notifications/:id/read', [NotificationsController, 'read'])\n router.post('impersonation/stop', [AdminUsersController, 'stopImpersonation'])\n })\n .use(middleware.auth())\n\nrouter\n .group(() => {\n router.get('/', ({ response }) => response.redirect('/admin/users'))\n router.get('users', [AdminUsersController, 'index'])\n router.get('users/:id', [AdminUsersController, 'show'])\n router.post('users/:id/roles', [AdminUsersController, 'assignRole'])\n router.delete('users/:id/roles/:assignment', [AdminUsersController, 'removeRole'])\n router.post('users/:id/org-units', [AdminUsersController, 'assignOrgUnit'])\n router.delete('users/:id/org-units/:orgUnit', [AdminUsersController, 'removeOrgUnit'])\n router.post('users/:id/disable', [AdminUsersController, 'disable'])\n router.post('users/:id/enable', [AdminUsersController, 'enable'])\n router.post('users/:id/revoke-sessions', [AdminUsersController, 'revokeSessions'])\n router.post('users/:id/impersonate', [AdminUsersController, 'impersonate'])\n router.get('roles', [AdminRolesController, 'index'])\n router.post('roles', [AdminRolesController, 'store'])\n router.get('roles/:id', [AdminRolesController, 'show'])\n router.patch('roles/:id', [AdminRolesController, 'update'])\n router.delete('roles/:id', [AdminRolesController, 'destroy'])\n router.put('roles/:id/rules', [AdminRolesController, 'setRule'])\n router.delete('roles/:id/rules/:rule', [AdminRolesController, 'removeRule'])\n router.get('org-units', [AdminOrgUnitsController, 'index'])\n router.post('org-units', [AdminOrgUnitsController, 'store'])\n router.patch('org-units/:id', [AdminOrgUnitsController, 'update'])\n router.post('org-units/:id/move', [AdminOrgUnitsController, 'move'])\n router.delete('org-units/:id', [AdminOrgUnitsController, 'destroy'])\n router.get('activity', [AdminActivityController, 'index'])\n router.get('jobs', [AdminJobsController, 'index'])\n router.post('jobs/:id/retry', [AdminJobsController, 'retry'])\n router.get('settings', [AdminSettingsController, 'index'])\n router.get('setup', [SetupController, 'index'])\n router.post('setup/check/:service', [SetupController, 'check'])\n router.post('setup/identity', [SetupController, 'confirmIdentity'])\n router.post('setup/notification', [SetupController, 'notification'])\n router.post('settings/mail/test', [AdminSettingsController, 'testMail'])\n router.post('settings/mail/confirm', [AdminSettingsController, 'confirmMail'])\n router.put('settings', [AdminSettingsController, 'upsert'])\n router.delete('settings/:id', [AdminSettingsController, 'destroy'])\n })\n .prefix('admin')\n .use([middleware.auth(), middleware.admin()])\n","start/scheduler.ts":"import scheduler from 'adonisjs-scheduler/services/main'\nimport db from '@adonisjs/lucid/services/db'\nimport { Settings } from '@adula/kit'\n// Exactly one scheduler process is deployed. The worker owns outbox publication.\nscheduler.command('backup:verify').hourly().withoutOverlapping()\n// The monthly drill restores the latest snapshot and opens a record with its attachment.\nscheduler.command('backup:restore-test').monthly().withoutOverlapping()\nscheduler\n .call(async () => {\n await new Settings(db.connection().getWriteClient()).set(\n 'scheduler.heartbeat',\n new Date().toISOString()\n )\n })\n .everyThirtySeconds()\n .immediate()\n .withoutOverlapping()\n","start/validator.ts":"/*\n|--------------------------------------------------------------------------\n| Validator file\n|--------------------------------------------------------------------------\n|\n| The validator file is used for configuring global transforms for VineJS.\n| The transform below converts all VineJS date outputs from JavaScript\n| Date objects to Luxon DateTime instances, so that validated dates are\n| ready to use with Lucid models and other parts of the app that expect\n| Luxon DateTime.\n|\n*/\n\nimport { DateTime } from 'luxon'\nimport { VineDate } from '@vinejs/vine'\n\ndeclare module '@vinejs/vine/types' {\n interface VineGlobalTransforms {\n date: DateTime\n }\n}\n\nVineDate.transform((value) => DateTime.fromJSDate(value))\n","bin/console.ts":"const cleanupCodegen = process.argv[2] === \"codegen\"\n/*\n|--------------------------------------------------------------------------\n| Ace entry point\n|--------------------------------------------------------------------------\n|\n| The \"console.ts\" file is the entrypoint for booting the AdonisJS\n| command-line framework and executing commands.\n|\n| Commands do not boot the application, unless the currently running command\n| has \"options.startApp\" flag set to true.\n|\n*/\n\nawait import('reflect-metadata')\nconst { Ignitor, prettyPrintError } = await import('@adonisjs/core')\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n if (filePath.startsWith('./') || filePath.startsWith('../')) {\n return import(new URL(filePath, APP_ROOT).href)\n }\n return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n .tap((app) => {\n app.booting(async () => {\n await import('#start/env')\n })\n app.listen('SIGTERM', () => app.terminate())\n app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n })\n .ace()\n .handle(process.argv.splice(2))\n .catch((error) => {\n process.exitCode = 1\n prettyPrintError(error)\n })\n\n .finally(async () => {\n if (cleanupCodegen) {\n const { default: app } = await import('@adonisjs/core/services/app')\n if (app.container.hasBinding('cache.manager')) {\n const cache = await app.container.make('cache.manager')\n await cache.disconnectAll()\n }\n if (app.container.hasBinding('redis')) {\n const redis = await app.container.make('redis')\n await redis.quitAll()\n }\n }\n })\n","bin/server.ts":"/*\n|--------------------------------------------------------------------------\n| HTTP server entrypoint\n|--------------------------------------------------------------------------\n|\n| The \"server.ts\" file is the entrypoint for starting the AdonisJS HTTP\n| server. Either you can run this file directly or use the \"serve\"\n| command to run this file and monitor file changes\n|\n*/\n\nawait import('reflect-metadata')\nconst { Ignitor, prettyPrintError } = await import('@adonisjs/core')\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n if (filePath.startsWith('./') || filePath.startsWith('../')) {\n return import(new URL(filePath, APP_ROOT).href)\n }\n return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n .tap((app) => {\n app.booting(async () => {\n await import('#start/env')\n })\n app.listen('SIGTERM', () => app.terminate())\n app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n })\n .httpServer()\n .start()\n .catch((error) => {\n process.exitCode = 1\n prettyPrintError(error)\n })\n","bin/test.ts":"/*\n|--------------------------------------------------------------------------\n| Test runner entrypoint\n|--------------------------------------------------------------------------\n|\n| The \"test.ts\" file is the entrypoint for running tests using Japa.\n|\n| Either you can run this file directly or use the \"test\"\n| command to run this file and monitor file changes.\n|\n*/\n\nprocess.env.NODE_ENV = 'test'\n\nimport 'reflect-metadata'\nimport { Ignitor, prettyPrintError } from '@adonisjs/core'\nimport { configure, processCLIArgs, run } from '@japa/runner'\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n if (filePath.startsWith('./') || filePath.startsWith('../')) {\n return import(new URL(filePath, APP_ROOT).href)\n }\n return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n .tap((app) => {\n app.booting(async () => {\n await import('#start/env')\n })\n app.listen('SIGTERM', () => app.terminate())\n app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n })\n .testRunner()\n .configure(async (app) => {\n const { runnerHooks, ...config } = await import('../tests/bootstrap.js')\n\n processCLIArgs(process.argv.splice(2))\n configure({\n ...app.rcFile.tests,\n ...config,\n ...{\n setup: runnerHooks.setup,\n teardown: runnerHooks.teardown.concat([() => app.terminate()]),\n },\n })\n })\n .run(() => run())\n .catch((error) => {\n process.exitCode = 1\n prettyPrintError(error)\n })\n","ace.js":"/*\n|--------------------------------------------------------------------------\n| JavaScript entrypoint for running ace commands\n|--------------------------------------------------------------------------\n|\n| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD\n| PROCESS.\n|\n| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build\n|\n| Since, we cannot run TypeScript source code using \"node\" binary, we need\n| a JavaScript entrypoint to run ace commands.\n|\n| This file registers the \"ts-node/esm\" hook with the Node.js module system\n| and then imports the \"bin/console.ts\" file.\n|\n*/\n\n/**\n * Register hook to process TypeScript files using @poppinss/ts-exec\n */\nimport '@poppinss/ts-exec'\n\n/**\n * Import ace console entrypoint\n */\nawait import('./bin/console.js')\n","adonisrc.ts":"import { indexPages } from '@adonisjs/inertia'\nimport { indexEntities } from '@adonisjs/core'\nimport { defineConfig } from '@adonisjs/core/app'\nimport { generateRegistry } from '@tuyau/core/hooks'\n\nexport default defineConfig({\n /*\n |--------------------------------------------------------------------------\n | Experimental flags\n |--------------------------------------------------------------------------\n |\n | The following features will be enabled by default in the next major release\n | of AdonisJS. You can opt into them today to avoid any breaking changes\n | during upgrade.\n |\n */\n experimental: {},\n\n /*\n |--------------------------------------------------------------------------\n | Commands\n |--------------------------------------------------------------------------\n |\n | List of ace commands to register from packages. The application commands\n | will be scanned automatically from the \"./commands\" directory.\n |\n */\n commands: [\n () => import('@adonisjs/core/commands'),\n () => import('@adonisjs/lucid/commands'),\n () => import('@adonisjs/session/commands'),\n () => import('@adonisjs/inertia/commands'),\n () => import('@adula/kit/commands'),\n () => import('@adonisjs/cache/commands'),\n () => import('@nemoventures/adonis-jobs/commands'),\n () => import('adonisjs-scheduler/commands'),\n () => import('@adonisjs/mail/commands'),\n () => import('@jrmc/adonis-attachment/commands'),\n ],\n\n /*\n |--------------------------------------------------------------------------\n | Service providers\n |--------------------------------------------------------------------------\n |\n | List of service providers to import and register when booting the\n | application\n |\n */\n providers: [\n () => import('@adonisjs/core/providers/app_provider'),\n () => import('@adonisjs/core/providers/hash_provider'),\n {\n file: () => import('@adonisjs/core/providers/repl_provider'),\n environment: ['repl', 'test'],\n },\n () => import('@adonisjs/core/providers/vinejs_provider'),\n () => import('@adonisjs/core/providers/edge_provider'),\n () => import('@adonisjs/session/session_provider'),\n () => import('@adonisjs/vite/vite_provider'),\n () => import('@adonisjs/shield/shield_provider'),\n () => import('@adonisjs/static/static_provider'),\n () => import('@adonisjs/lucid/database_provider'),\n () => import('@adonisjs/cors/cors_provider'),\n () => import('@adonisjs/inertia/inertia_provider'),\n () => import('@adonisjs/auth/auth_provider'),\n () => import('#providers/api_provider'),\n () => import('@adula/kit/provider'),\n () => import('@adonisjs/redis/redis_provider'),\n () => import('@adonisjs/cache/cache_provider'),\n () => import('@nemoventures/adonis-jobs/queue_provider'),\n () => import('adonisjs-scheduler/scheduler_provider'),\n () => import('@jrmc/adonis-mcp/mcp_provider'),\n () => import('@adonisjs/limiter/limiter_provider'),\n () => import('@adonisjs/mail/mail_provider'),\n () => import('@jrmc/adonis-attachment/attachment_provider'),\n () => import('@adonisjs/ally/ally_provider'),\n () => import('@adonisjs/drive/drive_provider'),\n ],\n\n /*\n |--------------------------------------------------------------------------\n | Preloads\n |--------------------------------------------------------------------------\n |\n | List of modules to import before starting the application.\n |\n */\n preloads: [\n () => import('#start/routes'),\n () => import('#start/kernel'),\n () => import('#start/validator'),\n { file: () => import('#start/scheduler'), environment: ['console'] },\n ],\n\n /*\n |--------------------------------------------------------------------------\n | Tests\n |--------------------------------------------------------------------------\n |\n | List of test suites to organize tests by their type. Feel free to remove\n | and add additional suites.\n |\n */\n tests: {\n suites: [\n {\n files: ['tests/unit/**/*.spec.{ts,js}'],\n name: 'unit',\n timeout: 2000,\n },\n {\n files: ['tests/functional/**/*.spec.{ts,js}'],\n name: 'functional',\n timeout: 30000,\n },\n {\n files: ['tests/browser/**/*.spec.{ts,js}'],\n name: 'browser',\n timeout: 300000,\n },\n ],\n forceExit: false,\n },\n\n /*\n |--------------------------------------------------------------------------\n | Metafiles\n |--------------------------------------------------------------------------\n |\n | A collection of files you want to copy to the build folder when creating\n | the production build.\n |\n */\n metaFiles: [\n { pattern: 'company-identity.json', reloadServer: false },\n { pattern: 'docs/design-identity.md', reloadServer: false },\n { pattern: 'inertia/brand.ts', reloadServer: false },\n { pattern: 'inertia/css/brand.css', reloadServer: false },\n {\n pattern: 'resources/views/**/*.edge',\n reloadServer: false,\n },\n {\n pattern: 'public/**',\n reloadServer: false,\n },\n ],\n\n hooks: {\n init: [\n indexEntities({\n transformers: { enabled: true, withSharedProps: true },\n }),\n indexPages({ framework: 'react' }),\n generateRegistry(),\n ],\n buildStarting: [() => import('@adonisjs/vite/build_hook')],\n },\n})\n","components.json":"{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"new-york\",\n \"rsc\": false,\n \"tsx\": true,\n \"rtl\": true,\n \"tailwind\": {\n \"config\": \"\",\n \"css\": \"inertia/css/kit.css\",\n \"baseColor\": \"neutral\",\n \"cssVariables\": true\n },\n \"aliases\": {\n \"components\": \"~/components\",\n \"ui\": \"~/components/ui\",\n \"utils\": \"~/lib/utils\",\n \"lib\": \"~/lib\",\n \"hooks\": \"~/hooks\"\n },\n \"iconLibrary\": \"lucide\"\n}\n","tsconfig.json":"{\n \"extends\": \"@adonisjs/tsconfig/tsconfig.app.json\",\n \"exclude\": [\"node_modules\", \"build\", \"inertia\"],\n \"compilerOptions\": {\n \"rootDir\": \"./\",\n \"jsx\": \"react\",\n \"outDir\": \"./build\",\n \"paths\": {\n \"~/*\": [\"./inertia/*\"]\n }\n },\n \"references\": [\n {\n \"path\": \"./tsconfig.inertia.json\"\n }\n ]\n}\n","tsconfig.inertia.json":"/**\n * This file only exists to avoid the circular reference between the Inertia\n * codebase and the backend codebase, which takes place because of the\n * codegen. We have Inertia app referencing backend code and backend\n * code referencing Inertia pages for inferring props types.\n *\n * The main part here is \"composite: true\"\n */\n{\n \"extends\": \"./inertia/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"./inertia\",\n \"composite\": true\n },\n \"include\": [\"./inertia/**/*.ts\", \"./inertia/**/*.tsx\"]\n}\n","vite.config.ts":"import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport adonisjs from '@adonisjs/vite/client'\nimport tailwindcss from '@tailwindcss/vite'\n\nexport default defineConfig({\n plugins: [\n react(),\n adonisjs({ entryPoints: ['inertia/app.tsx'], reload: ['resources/views/**/*.edge'] }),\n tailwindcss(),\n ],\n\n /**\n * Define aliases for importing modules from\n * your frontend code\n */\n resolve: {\n alias: {\n '~/': `${import.meta.dirname}/inertia/`,\n '@generated': `${import.meta.dirname}/.adonisjs/client/`,\n },\n },\n\n server: {\n watch: {\n ignored: ['**/storage/**', '**/tmp/**'],\n },\n },\n})\n","eslint.config.js":"import { configApp } from '@adonisjs/eslint-config'\nimport kit from '@adula/kit/eslint'\n\nexport default configApp({\n files: ['app/modules/**/*.ts', 'app/controllers/**/*.ts'],\n plugins: { adula: kit },\n rules: {\n 'adula/no-cross-module-controller': 'error',\n 'adula/no-direct-to-json': 'error',\n 'adula/no-kit-patching': 'error',\n },\n})\n","database/schema_rules.ts":"import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator'\n\nexport default {} satisfies SchemaRules\n","tests/helpers/resource_contract.ts":"import { test } from '@japa/runner'\nimport User from '#models/user'\nimport app from '@adonisjs/core/services/app'\nimport db from '@adonisjs/lucid/services/db'\nimport { kit } from '#services/kit'\nimport { randomUUID } from 'node:crypto'\nimport { columnName } from '@adula/kit'\nimport type { Field, RecordData, Resource, SerializedRecord } from '@adula/kit'\n\nexport type FixtureContext = { userId: number; orgUnitId: number; unique: string }\nexport type ContractFixture = {\n input: RecordData\n /** Expected public values after validation; relations have separate tests. */\n expected: SerializedRecord\n /** Normalized database values for writable fields excluded from the response. */\n stored?: RecordData\n /** All live child rows after creation, ordered by ID, using public field names. */\n inline?: Record<string, RecordData[]>\n /** A valid update for this same record, including required validator fields. */\n update: RecordData\n updated: SerializedRecord\n updatedStored?: RecordData\n updatedInline?: Record<string, RecordData[]>\n}\nexport type ResourceFixture = (\n context: FixtureContext\n) => Promise<ContractFixture> | ContractFixture\n\nfunction storedValue(field: Field, value: unknown) {\n if (!(value instanceof Date)) return value\n return field.type === 'date'\n ? [\n value.getFullYear(),\n String(value.getMonth() + 1).padStart(2, '0'),\n String(value.getDate()).padStart(2, '0'),\n ].join('-')\n : value.toISOString()\n}\n\n/** Fixtures belong to the application and exercise its real validators and HTTP routes. */\nexport function resourceContract(name: string, fixture: ResourceFixture) {\n test.group(`HTTP security contract: ${name}`, (group) => {\n let denied: User\n let writer: User\n let reader: User\n let outsider: User\n let orgUnitId: number\n let resource: Resource\n const base = `/resources/${name}`\n const fresh = () => fixture({ userId: writer.id, orgUnitId, unique: randomUUID() })\n const input = (values: RecordData) => ({ ...values, ...(resource.scoped ? { orgUnitId } : {}) })\n const version = (row: SerializedRecord) => (resource.version ? { version: row.version } : {})\n\n group.setup(async () => {\n const knex = db.connection().getWriteClient()\n const databaseInfo = await knex.raw('SELECT current_database() AS name')\n const database = databaseInfo.rows[0].name\n if (!app.inTest || !database.endsWith('_test'))\n throw new Error('Resource contracts require a dedicated *_test database')\n resource = kit().registry.get(name)\n const suffix = randomUUID()\n const users = []\n for (const label of ['denied', 'writer', 'reader', 'outside']) {\n users.push(\n await User.create({\n fullName: 'مستخدم الاختبار',\n email: `${label}-${suffix}@example.test`,\n password: 'a-long-test-password-123',\n })\n )\n }\n ;[denied, writer, reader, outsider] = users\n const [org, outside] = await knex('org_units')\n .insert([\n { name: 'نطاق الاختبار', type: 'root', path: `contract_${suffix.replaceAll('-', '_')}` },\n { name: 'نطاق آخر', type: 'root', path: `outside_${suffix.replaceAll('-', '_')}` },\n ])\n .returning('id')\n orgUnitId = org.id\n const [writeRole, readRole] = await knex('roles')\n .insert([\n { name: `writer-${suffix}`, permission_level: 1 },\n { name: `reader-${suffix}`, permission_level: 0 },\n ])\n .returning('id')\n await knex('role_rules').insert([\n { role_id: writeRole.id, subject: 'all', action: 'manage' },\n { role_id: readRole.id, subject: 'all', action: 'view' },\n ])\n await knex('user_roles').insert([\n { user_id: writer.id, role_id: writeRole.id },\n { user_id: outsider.id, role_id: writeRole.id },\n { user_id: reader.id, role_id: readRole.id },\n ])\n await knex('user_org_units').insert([\n { user_id: writer.id, org_unit_id: orgUnitId },\n { user_id: reader.id, org_unit_id: orgUnitId },\n { user_id: outsider.id, org_unit_id: outside.id },\n ])\n })\n\n for (const [method, suffix] of [\n ['get', ''],\n ['get', '/create'],\n ['get', '/999999/edit'],\n ['get', '/999999'],\n ['post', ''],\n ['patch', '/999999'],\n ['delete', '/999999'],\n ['post', '/999999/submit'],\n ['post', '/999999/cancel'],\n ] as const) {\n test(`${method.toUpperCase()} ${suffix || '/'} returns 403 without roles`, async ({\n client,\n }) => {\n const response = await client[method](`${base}${suffix}`)\n .loginAs(denied)\n .withCsrfToken()\n .header('Accept', 'application/json')\n response.assertStatus(403)\n })\n }\n test('resource route is protected from anonymous access', async ({ client }) => {\n const response = await client.get(base).header('Accept', 'application/json')\n response.assertStatus(401)\n })\n test('existing records enforce scope on reads and writes; central resources stay shared', async ({\n client,\n assert,\n }) => {\n const values = await fresh()\n const created = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input(values.input))\n created.assertStatus(201)\n const row = created.body().data\n const visible = await client\n .get(`${base}/${row.id}`)\n .loginAs(writer)\n .header('Accept', 'application/json')\n visible.assertStatus(200)\n assert.equal(visible.body().data.id, row.id)\n const outside = await client\n .get(`${base}/${row.id}`)\n .loginAs(outsider)\n .header('Accept', 'application/json')\n outside.assertStatus(resource.scoped ? 404 : 200)\n if (!resource.scoped) return\n const listing = await client.get(base).loginAs(outsider).header('Accept', 'application/json')\n listing.assertStatus(200)\n assert.notInclude(\n listing.body().data.map((entry: SerializedRecord) => entry.id),\n row.id\n )\n const edit = await client\n .get(`${base}/${row.id}/edit`)\n .loginAs(outsider)\n .header('Accept', 'application/json')\n edit.assertStatus(404)\n const changed = await client\n .patch(`${base}/${row.id}`)\n .loginAs(outsider)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json({ ...values.update, ...version(row) })\n changed.assertStatus(404)\n const deleted = await client\n .delete(`${base}/${row.id}`)\n .loginAs(outsider)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(version(row))\n deleted.assertStatus(404)\n const after = await client\n .get(`${base}/${row.id}`)\n .loginAs(writer)\n .header('Accept', 'application/json')\n after.assertStatus(200)\n assert.deepEqual(after.body().data, row)\n })\n test('fixture fields round-trip, private values stay hidden, updates and soft deletion persist', async ({\n client,\n assert,\n }) => {\n const values = await fresh()\n for (const key of resource.form)\n assert.property(values.input, key, `Missing ${name}.${key} fixture`)\n const created = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input(values.input))\n created.assertStatus(201)\n const row = created.body().data\n assert.isNumber(row.id)\n for (const [key, value] of Object.entries(values.expected))\n assert.deepEqual(row[key], value, key)\n const exposed = new Set([\n 'id',\n ...(resource.serialize ?? [...resource.list, ...resource.show]),\n ...(resource.scoped ? ['orgUnitId'] : []),\n ...(resource.version ? ['version'] : []),\n ...(resource.submittable ? ['docStatus'] : []),\n ])\n for (const key of Object.keys(row))\n assert.isTrue(exposed.has(key), `Unexpected serialized field ${key}`)\n const assertStored = async (\n submitted: RecordData,\n expected: SerializedRecord,\n stored: RecordData = {},\n inline: Record<string, RecordData[]> = {}\n ) => {\n const persistedRow = await db\n .connection()\n .getWriteClient()(name)\n .where('id', row.id)\n .first()\n const expectations = { ...expected, ...stored }\n for (const key of Object.keys(submitted)) {\n if (!resource.form.includes(key)) continue\n const field = resource.fields[key]\n if (field.type === 'hasMany') {\n assert.property(inline, key, `Missing expected ${name}.${key} child rows`)\n const child = kit().registry.get(field.resource)\n const foreignKey = child.fields[field.foreignKey].column ?? columnName(field.foreignKey)\n const rows = await db\n .connection()\n .getWriteClient()(child.name)\n .where(foreignKey, row.id)\n .whereNull('deleted_at')\n .orderBy('id')\n assert.lengthOf(rows, inline[key].length)\n for (const [index, entry] of inline[key].entries()) {\n for (const childKey of child.form.filter((entryKey) => entryKey !== field.foreignKey))\n assert.property(entry, childKey, `Missing expected ${field.resource}.${childKey}`)\n for (const [childKey, value] of Object.entries(entry)) {\n const childField = child.fields[childKey]\n assert.exists(childField, `Unknown expected child field ${childKey}`)\n assert.deepEqual(\n storedValue(childField, rows[index][childField.column ?? columnName(childKey)]),\n value\n )\n }\n }\n } else {\n assert.property(expectations, key, `Missing stored ${name}.${key} expectation`)\n if (exposed.has(key))\n assert.property(expected, key, `Missing public ${name}.${key} expectation`)\n assert.deepEqual(\n storedValue(field, persistedRow[field.column ?? columnName(key)]),\n expectations[key],\n key\n )\n }\n }\n }\n await assertStored(values.input, values.expected, values.stored, values.inline)\n const shown = await client\n .get(`${base}/${row.id}`)\n .loginAs(reader)\n .header('Accept', 'application/json')\n shown.assertStatus(200)\n for (const [key, field] of Object.entries(resource.fields)) {\n if (field.permissionLevel || resource.hidden?.includes(key))\n assert.notProperty(shown.body().data, key)\n }\n for (const key of ['createdBy', 'updatedBy', 'deletedAt', 'orgPath', 'searchVector']) {\n const invalid = await client\n .patch(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json({ ...values.update, ...version(row), [key]: writer.id })\n invalid.assertStatus(422)\n assert.equal(invalid.body().error.code, 'E_FIELD_NOT_WRITABLE')\n }\n const updated = await client\n .patch(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json({ ...values.update, ...version(row) })\n updated.assertStatus(200)\n for (const [key, value] of Object.entries(values.updated))\n assert.deepEqual(updated.body().data[key], value, key)\n await assertStored(values.update, values.updated, values.updatedStored, values.updatedInline)\n if (resource.version) {\n assert.equal(updated.body().data.version, Number(row.version) + 1)\n const stale = await client\n .patch(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json({ ...values.update, ...version(row) })\n stale.assertStatus(409)\n }\n const persisted = await client\n .get(`${base}/${row.id}`)\n .loginAs(writer)\n .header('Accept', 'application/json')\n persisted.assertStatus(200)\n assert.deepEqual(persisted.body().data, updated.body().data)\n const removed = await client\n .delete(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(version(updated.body().data))\n removed.assertStatus(200)\n const stored = await db.connection().getWriteClient()(name).where('id', row.id).first()\n assert.exists(stored.deleted_at)\n assert.equal(stored.created_by, writer.id)\n const missing = await client\n .get(`${base}/${row.id}`)\n .loginAs(writer)\n .header('Accept', 'application/json')\n missing.assertStatus(404)\n })\n test('attachment fields reject uploads owned by another user and never serve them', async ({\n client,\n assert,\n }) => {\n const keys = resource.form.filter((key) => resource.fields[key].type === 'attachment')\n if (!keys.length) return\n const values = await fresh()\n for (const key of keys) {\n const foreign = await client\n .post('/attachments')\n .loginAs(outsider)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .fields({ resource: name, field: key })\n .file('file', Buffer.from(`foreign ${randomUUID()}`), {\n filename: 'foreign.txt',\n contentType: 'text/plain',\n })\n foreign.assertStatus(201)\n const rejected = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input({ ...values.input, [key]: foreign.body().data.id }))\n rejected.assertStatus(422)\n assert.equal(rejected.body().error.code, 'E_ATTACHMENT')\n const download = await client\n .get(foreign.body().data.url)\n .loginAs(writer)\n .header('Accept', 'application/json')\n download.assertStatus(404)\n }\n })\n test('unique constraints reject duplicates and allow reuse after soft deletion', async ({\n client,\n assert,\n }) => {\n const keys = Object.entries(resource.fields).filter(([, field]) => field.unique)\n if (!keys.length) return\n const values = await fresh()\n const created = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input(values.input))\n created.assertStatus(201)\n const row = created.body().data\n const knex = db.connection().getWriteClient()\n const stored = await knex(name).where('id', row.id).first()\n const distinct = await fresh()\n const second = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input(distinct.input))\n second.assertStatus(201)\n // Read-only sequence values also require real PostgreSQL uniqueness assertions.\n for (const [key, field] of keys) {\n const column = field.column ?? columnName(key)\n assert.isNotNull(stored[column], `Missing unique fixture ${key}`)\n await assert.rejects(\n () =>\n knex(name)\n .where('id', second.body().data.id)\n .update({ [column]: stored[column] }),\n /duplicate key/\n )\n }\n for (const [key, field] of keys) {\n if (field.sequence || !resource.form.includes(key)) continue\n const other = await fresh()\n const rejected = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input({ ...other.input, [key]: values.input[key] }))\n rejected.assertStatus(409)\n assert.equal(rejected.body().error.code, 'E_DUPLICATE')\n }\n const removed = await client\n .delete(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(version(row))\n removed.assertStatus(200)\n for (const [key, field] of keys) {\n const column = field.column ?? columnName(key)\n assert.equal(\n await knex(name)\n .where('id', second.body().data.id)\n .update({ [column]: stored[column] }),\n 1\n )\n }\n })\n })\n}\n","tests/bootstrap.ts":"import { assert } from '@japa/assert'\nimport app from '@adonisjs/core/services/app'\nimport type { Config } from '@japa/runner/types'\nimport { pluginAdonisJS } from '@japa/plugin-adonisjs'\nimport { dbAssertions } from '@adonisjs/lucid/plugins/db'\nimport testUtils from '@adonisjs/core/services/test_utils'\nimport { browserClient } from '@japa/browser-client'\nimport { authBrowserClient } from '@adonisjs/auth/plugins/browser_client'\nimport { sessionBrowserClient } from '@adonisjs/session/plugins/browser_client'\nimport { apiClient } from '@japa/api-client'\nimport { authApiClient } from '@adonisjs/auth/plugins/api_client'\nimport { sessionApiClient } from '@adonisjs/session/plugins/api_client'\nimport { shieldApiClient } from '@adonisjs/shield/plugins/api_client'\nimport { inertiaApiClient } from '@adonisjs/inertia/plugins/api_client'\nimport env from '#start/env'\nimport cache from '@adonisjs/cache/services/main'\nimport queue from '@nemoventures/adonis-jobs/services/main'\nimport { createServer } from 'node:http'\nimport type { Socket } from 'node:net'\n\n/**\n * This file is imported by the \"bin/test.ts\" entrypoint file\n */\n\n/**\n * Configure Japa plugins in the plugins array.\n * Learn more - https://japa.dev/docs/runner-config#plugins-optional\n */\nexport const plugins: Config['plugins'] = [\n assert(),\n pluginAdonisJS(app),\n dbAssertions(app),\n browserClient({ runInSuites: ['browser'] }),\n sessionBrowserClient(app),\n authBrowserClient(app),\n apiClient(),\n sessionApiClient(app),\n authApiClient(app),\n shieldApiClient(),\n inertiaApiClient(app),\n]\n\n/**\n * Configure lifecycle function to run before and after all the\n * tests.\n *\n * The setup functions are executed before all the tests\n * The teardown functions are executed after all the tests\n */\nexport const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {\n setup: [\n async () => {\n if (!env.get('DB_DATABASE').endsWith('_test') || !app.inTest)\n throw new Error('HTTP tests require a dedicated *_test database')\n // Both stores use the explicit application-specific test prefix and Redis DB 15.\n await queue.clear(['events'])\n await cache.clear()\n await testUtils.db().migrate()\n\n },\n ],\n teardown: [],\n}\n\n/**\n * Configure suites by tapping into the test suite instance.\n * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks\n */\nexport const configureSuite: Config['configureSuite'] = (suite) => {\n if (['browser', 'functional', 'e2e'].includes(suite.name)) {\n return suite.setup(async () => {\n const sockets = new Set<Socket>()\n const stop = await testUtils.httpServer().start((handler) => {\n const server = createServer(handler)\n server.on('connection', (socket) => {\n sockets.add(socket)\n socket.once('close', () => sockets.delete(socket))\n })\n return server\n })\n return async () => {\n const closing = stop()\n // Tests are finished; close preview/HMR connections too, including upgraded sockets.\n for (const socket of sockets) socket.destroy()\n await closing\n }\n })\n }\n}\n","commands/adula_worker.ts":"import { BaseCommand } from '@adonisjs/core/ace'\n\nexport default class AdulaWorker extends BaseCommand {\n static commandName = 'adula:worker'\n static description = 'Run the queue consumer and transactional outbox publisher'\n static options = { startApp: true, staysAlive: true }\n async run() {\n const { publishEvents } = await import('#services/events')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { Settings } = await import('@adula/kit')\n const worker = await this.kernel.exec('queue:work', [])\n if (worker.error) throw worker.error\n let active: Promise<void> | undefined\n let stopped = false\n const tick = () => {\n if (active || stopped) return\n active = (async () => {\n try {\n await publishEvents()\n await new Settings(db.connection().getWriteClient()).set(\n 'worker.heartbeat',\n new Date().toISOString()\n )\n } catch (error) {\n this.logger.error(error instanceof Error ? error.message : String(error))\n }\n })().finally(() => {\n active = undefined\n })\n }\n const timer = setInterval(tick, 1000)\n this.app.terminating(async () => {\n stopped = true\n clearInterval(timer)\n await active\n })\n tick()\n }\n}\n","commands/adula_outbox.ts":"import { BaseCommand } from '@adonisjs/core/ace'\n\nexport default class AdulaOutbox extends BaseCommand {\n static commandName = 'adula:outbox'\n static description = 'Publish one locked batch of durable events to the queue'\n static options = { startApp: true }\n async run() {\n const { publishEvents } = await import('#services/events')\n this.logger.info(`Published ${await publishEvents()} events`)\n }\n}\n","commands/adula_runtime_health.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\n\nexport default class AdulaRuntimeHealth extends BaseCommand {\n static commandName = 'adula:runtime:health'\n static description = 'Check the worker or single scheduler heartbeat'\n static options = { startApp: true }\n @flags.string({ default: 'worker' }) declare service: string\n async run() {\n if (!['worker', 'scheduler'].includes(this.service)) throw new Error('Unknown service')\n const { Settings } = await import('@adula/kit')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const last = await new Settings(db.connection().getWriteClient()).get<string>(\n `${this.service}.heartbeat`\n )\n const age = Date.now() - Date.parse(last ?? '')\n const healthy = Number.isFinite(age) && age >= 0 && age < 60000\n this.logger.log(`${this.service}: ${healthy ? 'healthy' : 'unhealthy'}`)\n if (!healthy) this.exitCode = 1\n }\n}\n","commands/adula_restore_reconcile.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\n\nexport default class AdulaRestoreReconcile extends BaseCommand {\n static commandName = 'adula:restore:reconcile'\n static description =\n 'After a restore, rebuild the event queue and invalidate cached authorization'\n static options = { startApp: true }\n @flags.boolean() declare force: boolean\n async run() {\n if (!this.force)\n throw new Error(\n 'Stop web, worker and scheduler first; then pass --force after restoring the database'\n )\n const { default: queue } = await import('@nemoventures/adonis-jobs/services/main')\n const { default: cache } = await import('@adonisjs/cache/services/main')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n // This queue contains only deliveries whose authoritative records live in outbox.\n await queue.clear(['events'])\n await db\n .connection()\n .getWriteClient()\n .transaction(async (trx) => {\n await trx('outbox').update({ published_at: null })\n await trx('authorization_revision').where('id', 1).increment('version', 1)\n })\n await cache.clear()\n this.logger.success(\n 'Durable events will replay with listener deduplication; authorization cache cleared'\n )\n }\n}\n","commands/backup_verify.ts":"import { BaseCommand } from '@adonisjs/core/ace'\nimport { execFile } from 'node:child_process'\nimport { promisify } from 'node:util'\n\nexport default class BackupVerify extends BaseCommand {\n static commandName = 'backup:verify'\n static description = 'Verify recent database, uploads and checksum objects in offsite storage'\n static options = { startApp: true }\n async run() {\n const { verifyBackup } = await import('@adula/kit')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { default: env } = await import('#start/env')\n const prefix = env.get('BACKUP_S3_PREFIX') ?? 'adula'\n const status = await verifyBackup(\n db.connection().getWriteClient(),\n async () => {\n const endpoint = env.get('BACKUP_S3_ENDPOINT')\n const bucket = env.get('BACKUP_S3_BUCKET')\n if (!endpoint || !bucket) throw new Error('Offsite storage is not configured')\n const { stdout } = await promisify(execFile)(\n 'aws',\n [\n '--endpoint-url',\n endpoint,\n 's3api',\n 'list-objects-v2',\n '--bucket',\n bucket,\n '--prefix',\n prefix + '/',\n '--output',\n 'json',\n ],\n {\n timeout: 60000,\n maxBuffer: 16 * 1024 * 1024,\n windowsHide: true,\n env: {\n ...process.env,\n AWS_ACCESS_KEY_ID: env.get('BACKUP_S3_ACCESS_KEY_ID'),\n AWS_SECRET_ACCESS_KEY: env.get('BACKUP_S3_SECRET_ACCESS_KEY'),\n AWS_DEFAULT_REGION: env.get('BACKUP_S3_REGION'),\n AWS_PAGER: '',\n },\n }\n )\n const result = JSON.parse(stdout) as {\n Contents?: { Key: string; Size: number; LastModified: string }[]\n }\n return (result.Contents ?? []).map((item) => ({\n key: item.Key,\n size: item.Size,\n modifiedAt: item.LastModified,\n }))\n },\n Date.now(),\n prefix\n )\n const { Settings } = await import('@adula/kit')\n const { backupFingerprint } = await import('#services/initial_setup')\n await new Settings(db.connection().getWriteClient()).set('setup.backup_check', {\n fingerprint: backupFingerprint(),\n at: status.checkedAt,\n })\n this.logger.log(JSON.stringify(status))\n if (!status.healthy) this.exitCode = 1\n }\n}\n","commands/backup_create.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport { resolve, join, basename } from 'node:path'\nimport { createReadStream } from 'node:fs'\nimport { stat, writeFile } from 'node:fs/promises'\nimport { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'\n\nexport default class BackupCreate extends BaseCommand {\n static commandName = 'backup:create'\n static description =\n 'Create a consistent database and all-disk attachment snapshot; publish COMPLETE last'\n static options = { startApp: true }\n\n @flags.string({ required: true, description: 'New snapshot directory (must not exist)' })\n declare snapshot: string\n @flags.string({\n description: 'Offsite key prefix (defaults to adula/<snapshot directory name>/)',\n })\n declare prefix: string\n\n async run() {\n const { default: env } = await import('#start/env')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { Settings } = await import('@adula/kit')\n const { createSnapshot } = await import('#services/backup_snapshot')\n const { publishSnapshot } = await import('#services/backup_publish')\n const directory = resolve(this.snapshot)\n const bucket = env.get('BACKUP_S3_BUCKET')\n const prefix =\n this.prefix ?? `${env.get('BACKUP_S3_PREFIX') ?? 'adula'}/${basename(directory)}/`\n if (!/^adula\\/[a-zA-Z0-9/_-]+\\/$/.test(prefix) || prefix.includes('//'))\n throw new Error('Invalid offsite prefix')\n const client = bucket\n ? new S3Client({\n region: env.get('BACKUP_S3_REGION'),\n endpoint: env.get('BACKUP_S3_ENDPOINT'),\n credentials: {\n accessKeyId: env.get('BACKUP_S3_ACCESS_KEY_ID')!,\n secretAccessKey: env.get('BACKUP_S3_SECRET_ACCESS_KEY')!,\n },\n })\n : null\n try {\n if (client) {\n try {\n await client.send(new HeadObjectCommand({ Bucket: bucket, Key: prefix + 'COMPLETE' }))\n throw new Error('Refusing to overwrite a complete offsite snapshot')\n } catch (error) {\n if (\n (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode !== 404\n )\n throw error\n }\n }\n await createSnapshot(directory)\n if (client) {\n await publishSnapshot(directory, {\n exists: async () => {\n try {\n await client.send(new HeadObjectCommand({ Bucket: bucket, Key: prefix + 'COMPLETE' }))\n return true\n } catch (error) {\n if (\n (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode ===\n 404\n )\n return false\n throw error\n }\n },\n put: async (name, path) => {\n const info = path ? await stat(path) : null\n await client.send(\n new PutObjectCommand({\n Bucket: bucket,\n Key: prefix + name,\n Body: path ? createReadStream(path) : '',\n ContentLength: info?.size ?? 0,\n })\n )\n },\n read: async (name) => {\n const response = await client.send(\n new GetObjectCommand({ Bucket: bucket, Key: prefix + name })\n )\n if (!response.Body) throw new Error('Missing offsite body: ' + name)\n return response.Body as AsyncIterable<Uint8Array>\n },\n })\n await new Settings(db.connection().getWriteClient()).set(\n 'backup.lastOffsite',\n new Date().toISOString()\n )\n }\n await writeFile(join(directory, 'COMPLETE'), '')\n this.logger.success(`Backup complete: ${directory}`)\n } finally {\n client?.destroy()\n }\n }\n}\n","commands/backup_verify_snapshot.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport { mkdtemp, rm } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\n\nexport default class BackupVerifySnapshot extends BaseCommand {\n static commandName = 'backup:verify-snapshot'\n static description = 'Validate snapshot checksums, manifest and archive before database recovery'\n static options = { startApp: true }\n @flags.string({ required: true })\n declare snapshot: string\n\n async run() {\n const { verifySnapshot, extractSnapshot, verifyAttachments } =\n await import('#services/backup_snapshot')\n const { default: env } = await import('#start/env')\n const directory = resolve(this.snapshot)\n const files = await verifySnapshot(directory)\n if (!files && env.get('DRIVE_DISK') !== 'local')\n throw new Error('Legacy snapshot does not contain S3 attachments')\n const extracted = await mkdtemp(join(tmpdir(), 'adula-verify-'))\n try {\n await extractSnapshot(directory, extracted)\n if (files) await verifyAttachments(files, extracted, files)\n this.logger.success('Snapshot integrity verified')\n } finally {\n await rm(extracted, { recursive: true, force: true })\n }\n }\n}\n","commands/backup_restore_files.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport { mkdtemp, rm } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\n\nexport default class BackupRestoreFiles extends BaseCommand {\n static commandName = 'backup:restore-files'\n static description =\n 'Restore verified attachment bytes to their original disks after database recovery'\n static options = { startApp: true }\n\n @flags.string({ required: true })\n declare snapshot: string\n @flags.boolean({\n description: 'Write original attachment keys; stop application traffic before recovery',\n })\n declare apply: boolean\n\n async run() {\n if (!this.apply)\n throw new Error('Use --apply after restoring the database with application traffic stopped')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { verifySnapshot, extractSnapshot, verifyAttachments, restoreAttachments } =\n await import('#services/backup_snapshot')\n const snapshot = resolve(this.snapshot)\n const files = await verifySnapshot(snapshot)\n if (!files) throw new Error('Legacy snapshots must use the local-files restore script')\n const extracted = await mkdtemp(join(tmpdir(), 'adula-recovery-'))\n try {\n await extractSnapshot(snapshot, extracted)\n await verifyAttachments(\n files,\n extracted,\n await db.connection().getWriteClient()('attachments').select('*')\n )\n await restoreAttachments(files, extracted, false)\n this.logger.success(\n `Restored and verified ${files.length} attachments on their original disks`\n )\n } finally {\n await rm(extracted, { recursive: true, force: true })\n }\n }\n}\n","commands/backup_restore_test.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport { execFile } from 'node:child_process'\nimport { randomUUID } from 'node:crypto'\nimport { mkdtemp, readdir, rm, stat } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\nimport { promisify } from 'node:util'\n\nconst run = promisify(execFile)\nconst SNAPSHOT = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}Z$/\n\ntype Report = {\n startedAt: string\n finishedAt?: string\n status: 'passed' | 'failed'\n snapshot?: string\n database?: string\n tables?: Record<string, number>\n verifiedFiles?: number\n attachment?: {\n id: number\n resource: string\n recordId: number\n field: string\n path: string\n size: number\n fileVerified: boolean\n } | null\n error?: string\n}\n\nasync function latestSnapshot(directory: string) {\n const entries = await readdir(directory, { withFileTypes: true })\n const candidates: string[] = []\n for (const entry of entries) {\n if (!entry.isDirectory() || !SNAPSHOT.test(entry.name)) continue\n try {\n await stat(join(directory, entry.name, 'COMPLETE'))\n candidates.push(entry.name)\n } catch {}\n }\n if (!candidates.length) throw new Error(`No complete snapshot found under ${directory}`)\n return join(directory, candidates.sort().at(-1)!)\n}\n\nexport default class BackupRestoreTest extends BaseCommand {\n static commandName = 'backup:restore-test'\n static description =\n 'Restore the latest snapshot into a temporary database and verify a record together with its attachment file'\n static options = { startApp: true }\n @flags.string({ description: 'Directory that holds dated snapshots (default: /backups)' })\n declare dir: string\n @flags.string({ description: 'Explicit snapshot directory instead of the latest one' })\n declare snapshot: string\n @flags.boolean({ description: 'Pass even when no record carries an attachment' })\n declare allowEmpty: boolean\n\n async run() {\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { default: env } = await import('#start/env')\n const { Settings } = await import('@adula/kit')\n const { isRelativeDiskPath } = await import('@adula/kit')\n const { registry } = await import('#start/modules')\n const { verifySnapshot, extractSnapshot, verifyAttachments, restoreAttachments } =\n await import('#services/backup_snapshot')\n const live = db.connection().getWriteClient()\n const settings = new Settings(live)\n const startedAt = new Date()\n const report: Report = { startedAt: startedAt.toISOString(), status: 'failed' }\n const stamp = startedAt\n .toISOString()\n .replace(/[-:]/g, '')\n .replace(/\\.\\d+Z$/, '')\n .toLowerCase()\n const database = `${env.get('DB_DATABASE').slice(0, 20)}_restore_${stamp}_${randomUUID().slice(0, 8)}_test`\n const pgEnv = {\n ...process.env,\n PGHOST: env.get('DB_HOST'),\n PGPORT: String(env.get('DB_PORT')),\n PGUSER: env.get('DB_USER'),\n PGPASSWORD: env.get('DB_PASSWORD'),\n }\n let extracted: string | undefined\n let connected = false\n let created = false\n try {\n const snapshot = this.snapshot\n ? resolve(this.snapshot)\n : await latestSnapshot(resolve(this.dir ?? '/backups'))\n report.snapshot = snapshot\n const files = await verifySnapshot(snapshot)\n await live.raw('CREATE DATABASE ??', [database])\n created = true\n report.database = database\n await run(\n 'pg_restore',\n [\n '--no-owner',\n '--no-privileges',\n '--exit-on-error',\n `--dbname=${database}`,\n join(snapshot, 'database.dump'),\n ],\n { env: pgEnv, windowsHide: true, timeout: 600000, maxBuffer: 16 * 1024 * 1024 }\n )\n extracted = await mkdtemp(join(tmpdir(), 'adula-restore-'))\n await extractSnapshot(snapshot, extracted)\n db.manager.add(database, {\n client: 'pg',\n connection: {\n host: env.get('DB_HOST'),\n port: env.get('DB_PORT'),\n user: env.get('DB_USER'),\n password: env.get('DB_PASSWORD'),\n database,\n },\n pool: { min: 0, max: 2 },\n })\n connected = true\n const restored = db.connection(database).getWriteClient()\n const tables: Record<string, number> = {}\n for (const resource of registry.all()) {\n const count = await restored(resource.name)\n .whereNull('deleted_at')\n .count('* as count')\n .first()\n tables[resource.name] = Number(count?.count ?? 0)\n }\n const attachments = await restored('attachments')\n .whereNull('deleted_at')\n .count('* as count')\n .first()\n tables.attachments = Number(attachments?.count ?? 0)\n report.tables = tables\n if (files) {\n await verifyAttachments(files, extracted, await restored('attachments').select('*'))\n await restoreAttachments(files, extracted, true)\n report.verifiedFiles = files.length\n } else if (await restored('attachments').whereNot('disk', 'local').first()) {\n throw new Error('Legacy snapshot does not contain non-local attachments')\n }\n const candidate = await restored('attachments')\n .whereNull('deleted_at')\n .whereNotNull('record_id')\n .orderBy('id', 'desc')\n .first()\n if (!candidate) {\n if (!this.allowEmpty)\n throw new Error(\n 'No record with an attachment exists in the snapshot; the drill cannot verify a file'\n )\n report.attachment = null\n } else {\n const resource = registry.all().find((entry) => entry.name === candidate.resource)\n if (!resource)\n throw new Error(\n `Attachment ${candidate.id} belongs to unknown resource ${candidate.resource}`\n )\n const record = await restored(resource.name)\n .where('id', candidate.record_id)\n .whereNull('deleted_at')\n .first('id')\n if (!record)\n throw new Error(\n `Record ${candidate.resource}#${candidate.record_id} for attachment ${candidate.id} is missing from the restored database`\n )\n if (!isRelativeDiskPath(candidate.path))\n throw new Error(`Unsafe attachment path: ${candidate.path}`)\n const file = await stat(\n join(extracted, files ? `${candidate.id}.bin` : candidate.path)\n ).catch(() => undefined)\n if (!file || !file.isFile())\n throw new Error(\n `Attachment ${candidate.id} file ${candidate.path} is missing from uploads.tar.gz`\n )\n if (file.size !== Number(candidate.size))\n throw new Error(\n `Attachment ${candidate.id} file ${candidate.path} has ${file.size} bytes, expected ${candidate.size}`\n )\n report.attachment = {\n id: Number(candidate.id),\n resource: String(candidate.resource),\n recordId: Number(candidate.record_id),\n field: String(candidate.field),\n path: String(candidate.path),\n size: Number(candidate.size),\n fileVerified: true,\n }\n }\n report.status = 'passed'\n report.finishedAt = new Date().toISOString()\n await settings.set('backup.lastRestoreTest', report.finishedAt)\n await settings.set('backup.lastRestoreTestReport', report)\n this.logger.success(`Restore drill passed: ${JSON.stringify(report)}`)\n } catch (error) {\n report.error = error instanceof Error ? error.message : String(error)\n report.finishedAt = new Date().toISOString()\n await settings.set('backup.lastRestoreTestReport', report)\n this.logger.error(`Restore drill failed: ${JSON.stringify(report)}`)\n this.exitCode = 1\n } finally {\n if (connected) await db.manager.close(database, true)\n if (created) await live.raw('DROP DATABASE IF EXISTS ?? WITH (FORCE)', [database])\n if (extracted) await rm(extracted, { recursive: true, force: true })\n }\n }\n}\n","database/schema.ts":"import { BaseModel, column } from '@adonisjs/lucid/orm'\nimport { DateTime } from 'luxon'\nexport class UserSchema extends BaseModel {\n static $columns = [\n 'createdAt',\n 'disabledAt',\n 'email',\n 'fullName',\n 'id',\n 'password',\n 'updatedAt',\n ] as const\n $columns = UserSchema.$columns\n @column.dateTime({ autoCreate: true })\n declare createdAt: DateTime\n @column.dateTime()\n declare disabledAt: DateTime | null\n @column()\n declare email: string\n @column()\n declare fullName: string | null\n @column({ isPrimary: true })\n declare id: number\n @column({ serializeAs: null })\n declare password: string\n @column.dateTime({ autoCreate: true, autoUpdate: true })\n declare updatedAt: DateTime | null\n}\n\n","commands/adula_setup.ts":"import { BaseCommand } from '@adonisjs/core/ace'\nimport { readFile } from 'node:fs/promises'\nimport hash from '@adonisjs/core/services/hash'\n\nexport default class Setup extends BaseCommand {\n static commandName = 'adula:setup'\n static description = 'Finish local setup using the private credentials generated by create-app'\n static options = { startApp: true }\n\n async run() {\n if (this.app.inProduction || this.app.inTest) throw new Error('Setup requires development mode')\n const credentials = await readFile(this.app.tmpPath('dev-admin.txt'), 'utf8')\n const email = /^Email: (.+)$/m.exec(credentials)?.[1]\n const password = /^Password: (.+)$/m.exec(credentials)?.[1]\n if (!email || !password || password.length < 24) throw new Error('Missing generated administrator credentials')\n const { default: User } = await import('#models/user')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const existing = await User.findBy('email', email)\n if (existing) {\n if (!(await hash.verify(existing.password, password))) throw new Error('Existing account does not match setup credentials')\n } else {\n if ((await User.query().first())) throw new Error('Setup cannot create an administrator in an existing application')\n await User.create({ email, password, fullName: 'مدير النظام' })\n }\n process.env.ADULA_ADMIN_EMAIL = email\n const installed = await this.kernel.exec('adula:install', [])\n if (installed.exitCode) throw new Error('Kit installation failed')\n const identity = JSON.parse(await readFile(this.app.makePath('company-identity.json'), 'utf8'))\n await db.from('org_units').whereNull('parent_id').update({ name: identity.company })\n this.logger.success('Local administrator, company, managed skills and shadcn UI are ready')\n }\n}\n","tests/functional/starter.spec.ts":"import { test } from '@japa/runner'\nimport { randomUUID } from 'node:crypto'\nimport User from '#models/user'\nimport db from '@adonisjs/lucid/services/db'\nimport mail from '@adonisjs/mail/services/main'\nimport UserInvitationNotification from '#mails/user_invitation_notification'\n\ntest('starter has healthy services and protects administration', async ({ client, assert }) => {\n const health = await client.get('/health')\n health.assertStatus(200)\n assert.equal(health.body().database, 'ok')\n assert.equal(health.body().redis, 'ok')\n const anonymous = await client.get('/admin/users').redirects(0)\n anonymous.assertStatus(302)\n const password = 'test-only-password-123'\n const member = await User.create({ email: `member-${randomUUID()}@example.test`, password })\n const login = await client.post('/login').withCsrfToken().redirects(0).form({ email: member.email, password })\n login.assertStatus(302)\n login.assertHeader('location', '/')\n const denied = await client.get('/admin/users').loginAs(member).header('Accept', 'application/json')\n denied.assertStatus(403)\n})\n\ntest('fresh installation includes permission-protected email invitations and acceptance', async ({ client, assert }) => {\n const knex = db.connection().getWriteClient()\n const password = 'invitation-password-123'\n const admin = await User.create({ email: `invite-admin-${randomUUID()}@example.test`, password })\n const [role] = await knex('roles').insert({ name: `invite-admin-${randomUUID()}` }).returning('id')\n await knex('role_rules').insert({ role_id: role.id, subject: 'all', action: 'manage' })\n await knex('user_roles').insert({ user_id: admin.id, role_id: role.id })\n const { mails } = mail.fake()\n try {\n const page = await client.get('/users/invite').loginAs(admin).withInertia()\n page.assertStatus(200)\n assert.isTrue(page.body().props.canInviteUsers)\n const email = `invited-${randomUUID()}@example.test`\n const sent = await client.post('/users/invite').loginAs(admin).withCsrfToken().header('Accept', 'application/json').json({ email, fullName: 'مستخدم مدعو' })\n sent.assertStatus(200)\n const [notification] = mails.sent((entry) => entry instanceof UserInvitationNotification) as UserInvitationNotification[]\n const token = notification.invitationUrl.split('/').pop()!\n const accepted = await client.post(`/invitations/${token}`).withCsrfToken().header('Accept', 'application/json').json({ password, passwordConfirmation: password })\n accepted.assertStatus(200)\n const user = await User.verifyCredentials(email, password)\n const denied = await client.post('/users/invite').loginAs(user).withCsrfToken().header('Accept', 'application/json').json({ email: `blocked-${email}`, fullName: 'غير مصرح' })\n denied.assertStatus(403)\n } finally { mail.restore() }\n})\n","package.json":"{\n \"name\": \"adula-app\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=24.0.0\"\n },\n \"scripts\": {\n \"start\": \"node bin/server.js\",\n \"build\": \"node ace build\",\n \"dev\": \"node ace serve --hmr\",\n \"test\": \"node ace test && prettier --write database/schema.ts\",\n \"lint\": \"eslint .\",\n \"format\": \"prettier --write .\",\n \"typecheck\": \"tsc --noEmit && tsc --noEmit --project inertia/tsconfig.json\"\n },\n \"imports\": {\n \"#controllers/*\": \"./app/controllers/*.js\",\n \"#exceptions/*\": \"./app/exceptions/*.js\",\n \"#models/*\": \"./app/models/*.js\",\n \"#mails/*\": \"./app/mails/*.js\",\n \"#services/*\": \"./app/services/*.js\",\n \"#listeners/*\": \"./app/listeners/*.js\",\n \"#events/*\": \"./app/events/*.js\",\n \"#generated/*\": \"./.adonisjs/server/*.js\",\n \"#middleware/*\": \"./app/middleware/*.js\",\n \"#transformers/*\": \"./app/transformers/*.js\",\n \"#validators/*\": \"./app/validators/*.js\",\n \"#providers/*\": \"./providers/*.js\",\n \"#policies/*\": \"./app/policies/*.js\",\n \"#abilities/*\": \"./app/abilities/*.js\",\n \"#database/*\": \"./database/*.js\",\n \"#tests/*\": \"./tests/*.js\",\n \"#start/*\": \"./start/*.js\",\n \"#config/*\": \"./config/*.js\",\n \"#modules/*\": \"./app/modules/*.js\"\n },\n \"devDependencies\": {\n \"@adonisjs/assembler\": \"8.5.0\",\n \"@adonisjs/eslint-config\": \"3.1.0\",\n \"@adonisjs/prettier-config\": \"1.5.0\",\n \"@adonisjs/tsconfig\": \"2.0.0\",\n \"@japa/api-client\": \"3.2.1\",\n \"@japa/assert\": \"4.2.0\",\n \"@japa/browser-client\": \"2.3.0\",\n \"@japa/plugin-adonisjs\": \"5.2.0\",\n \"@japa/runner\": \"5.3.0\",\n \"@poppinss/ts-exec\": \"1.4.4\",\n \"@tailwindcss/vite\": \"4.3.3\",\n \"@types/luxon\": \"3.7.5\",\n \"@types/node\": \"26.2.0\",\n \"@types/react\": \"19.2.18\",\n \"@types/react-dom\": \"19.2.5\",\n \"@vitejs/plugin-react\": \"6.1.0\",\n \"eslint\": \"10.9.0\",\n \"eslint-plugin-react\": \"7.37.5\",\n \"eslint-plugin-react-hooks\": \"7.1.1\",\n \"hot-hook\": \"1.0.0\",\n \"pino-pretty\": \"13.1.3\",\n \"playwright\": \"1.63.0\",\n \"prettier\": \"3.9.6\",\n \"shadcn\": \"4.21.0\",\n \"typescript\": \"6.0.3\",\n \"vite\": \"8.2.2\",\n \"youch\": \"4.1.1\",\n \"pnpm\": \"11.19.0\"\n },\n \"dependencies\": {\n \"@adonisjs/ally\": \"6.3.0\",\n \"@adonisjs/auth\": \"10.1.0\",\n \"@adonisjs/cache\": \"2.1.0\",\n \"@adonisjs/core\": \"7.5.0\",\n \"@adonisjs/cors\": \"3.0.0\",\n \"@adonisjs/drive\": \"4.0.0\",\n \"@adonisjs/inertia\": \"5.0.1\",\n \"@adonisjs/limiter\": \"3.0.1\",\n \"@adonisjs/lucid\": \"22.4.2\",\n \"@adonisjs/mail\": \"10.4.0\",\n \"@adonisjs/redis\": \"10.0.2\",\n \"@adonisjs/session\": \"8.1.0\",\n \"@adonisjs/shield\": \"9.0.0\",\n \"@adonisjs/static\": \"2.0.1\",\n \"@adonisjs/vite\": \"6.0.1\",\n \"@adula/kit\": \"0.2.0-alpha.1\",\n \"@adula/ui\": \"0.2.0-alpha.1\",\n \"@aws-sdk/client-s3\": \"^3.1134.0\",\n \"@aws-sdk/s3-request-presigner\": \"^3.1134.0\",\n \"@casl/ability\": \"7.0.1\",\n \"@casl/react\": \"7.0.1\",\n \"@fontsource/noto-sans-arabic\": \"5.3.0\",\n \"@hookform/resolvers\": \"5.9.1\",\n \"@inertiajs/core\": \"3.7.1\",\n \"@inertiajs/react\": \"3.7.0\",\n \"@jrmc/adonis-attachment\": \"5.2.1\",\n \"@jrmc/adonis-mcp\": \"2.0.0\",\n \"@nemoventures/adonis-jobs\": \"2.2.0\",\n \"@tanstack/react-table\": \"9.2.4\",\n \"@tanstack/react-virtual\": \"3.14.13\",\n \"@tuyau/core\": \"1.2.2\",\n \"@vinejs/vine\": \"4.4.0\",\n \"adonisjs-scheduler\": \"2.8.0\",\n \"axios\": \"1.19.0\",\n \"bullmq\": \"5.81.5\",\n \"class-variance-authority\": \"0.7.1\",\n \"cmdk\": \"1.1.1\",\n \"cn\": \"0.3.0\",\n \"date-fns\": \"4.4.0\",\n \"edge.js\": \"6.5.1\",\n \"lucide-react\": \"1.47.0\",\n \"luxon\": \"3.7.2\",\n \"pg\": \"8.16.3\",\n \"radix-ui\": \"1.6.7\",\n \"react\": \"19.2.8\",\n \"react-day-picker\": \"10.0.1\",\n \"react-dom\": \"19.2.8\",\n \"react-hook-form\": \"7.88.0\",\n \"reflect-metadata\": \"0.2.2\",\n \"sonner\": \"2.0.8\",\n \"tailwindcss\": \"4.3.3\",\n \"tw-animate-css\": \"1.4.0\",\n \"zod\": \"4.6.5\"\n },\n \"hotHook\": {\n \"boundaries\": [\n \"./app/controllers/**/*.ts\",\n \"./app/middleware/*.ts\"\n ]\n },\n \"overrides\": {\n \"eslint-plugin-react\": {\n \"eslint\": \"$eslint\"\n }\n },\n \"prettier\": \"@adonisjs/prettier-config\",\n \"packageManager\": \"pnpm@11.19.0\"\n}\n","pnpm-workspace.yaml":"packages:\n - '.'\nallowBuilds:\n '@swc/core': true\n esbuild: true\n '@parcel/watcher': true\n argon2: true\n msgpackr-extract: false\n exifreader: false\nminimumReleaseAgeExclude:\n - lucide-react@1.47.0\noverrides:\n eslint-plugin-react>eslint: 10.9.0\n monaco-editor>dompurify: 3.4.15\n node-cron>uuid: 11.1.1\n",".gitignore":"node_modules/\nbuild/\n.adonisjs/\n.env*\n!.env.example\ntmp/\nstorage/\npublic/assets/\n*.log\n",".prettierignore":".adonisjs\nnode_modules\nbuild\n.adula-packages\n"}}
|
|
1
|
+
{"version":"0.2.0-alpha.2","files":{"docs/initial-setup.md":"# Initial application setup\n\nAfter creation, sign in as administrator and open **الإعداد الأولي** (`/admin/setup`). Only users with `manage all` can access the page and its actions. Resume later without losing recorded results.\n\n1. Review and explicitly approve the installed company identity. Complete project-owned `docs/design-identity.md`, `company-identity.json`, `inertia/brand.ts` and brand assets if provisional. Checklist approval does not rewrite branding. Calendar/dialog preferences remain under settings.\n2. Send an internal test notification, open the inbox, mark it read and return. Evidence belongs to the signed-in administrator.\n3. Configure SMTP, restart web/worker processes, then select **إرسال بريد تجريبي**. The recipient is the administrator's stored email, never a request-supplied address. Compare the attempt reference in the email before selecting **وصلت الرسالة**; otherwise select **لم تصل الرسالة**. Reopen the dialog later if needed. Pending confirmations expire after 24 hours. Configuration changes invalidate old evidence. SMTP acceptance alone stays pending.\n4. Run the storage check: write a unique private test file, read/compare its content, then delete that file. Business attachments are untouched. Preserve local volumes or configure S3. Changing disks does not move existing files; use the documented storage migration flow.\n5. Check PostgreSQL/Redis connectivity. Run `node ace adula:worker` continuously and exactly one `node ace scheduler:run` under the deployment process manager. Review fresh heartbeats and failed jobs in **تشغيل النظام**. Connectivity does not prove job execution.\n6. Configure offsite backup storage and the supplied backup service. Run `node ace backup:verify` to inspect snapshot objects. Download an offsite snapshot and use `backup:restore-test` to verify a restored record and attachment in an isolated temporary database. Local snapshots do not establish offsite acceptance.\n7. Google/GitHub sign-in is optional and does not block the base release ([ADR 021](decisions/021-optional-oauth.md)); the owner declined it for the current setup. Leave credentials empty unless requested. When enabled, configure the provider and callback URLs. Try real sign-in in another session while retaining administrator access. Configuration alone never counts as verified login.\n\n## Environment configuration\n\n| Service | Variables |\n| ------- | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| Mail | `MAIL_FROM_NAME`, `MAIL_FROM_ADDRESS`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` |\n| TLS | `SMTP_SECURE` for implicit TLS; `SMTP_REQUIRE_TLS` for STARTTLS. Without overrides, port 465 uses implicit TLS and production requires TLS. |\n| File S3 | `DRIVE_DISK=s3`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET`, optional `AWS_ENDPOINT` |\n| Backups | `BACKUP_S3_ENDPOINT`, `BACKUP_S3_BUCKET`, `BACKUP_S3_REGION`, `BACKUP_S3_ACCESS_KEY_ID`, `BACKUP_S3_SECRET_ACCESS_KEY` |\n| OAuth | `APP_URL`, plus `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` |\n\nVerify the sender/domain with the mail provider. Keep secrets out of Git and generic JSON settings. Restart affected processes after environment changes and repeat the relevant test. Never disable certificate verification to make a test pass.\n\nS3 uses `supportsACL: false` with private visibility so uploads work with bucket-owner-enforced buckets and S3-compatible providers that disable ACLs. Keep public access blocked and scope IAM access to the intended bucket. This option belongs in `config/drive.ts`, not `.env`; it does not grant bucket permissions. After changing the configuration, restart the application and run the storage roundtrip check. A successful local-disk check does not validate S3.\n\n## Adding users\n\nFresh projects include **إضافة مستخدم** in user administration and **دعوة مستخدم** in navigation. Administrators may delegate only **دعوات المستخدمين → دعوة مستخدم** through the roles matrix and a deployment-wide role assignment. This permission does not expose the administrative user list or grant role-assignment authority.\n\nConfigure SMTP and `APP_URL` before sending invitations. Enter the user's name and email; the message contains a single-use link valid for 24 hours. The account is created only when the recipient chooses a password. The administrator then assigns business roles and organizational membership. To resend an unaccepted invitation, enter the same email after one minute; the earlier link is invalidated. Delivery failure is shown inside the dialog and can be retried. Existing accounts are managed from the users screen, never replaced by an invitation.\n\n## Evidence limits\n\nProtected `setup.*` settings hold setup evidence; `mail.delivery_test` holds per-administrator mail attempts. PostgreSQL tests cover races, stale confirmations, failures and evidence tampering. The loopback SMTP sink exercises a real SMTP conversation but proves no external inbox delivery. Production acceptance requires real infrastructure and administrator receipt confirmation.\n","app/controllers/account_sessions_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { Exception } from '@adonisjs/core/exceptions'\nimport { listUserSessions, revokeSession, revokeUserSessions } from '#services/sessions'\n\nexport default class AccountSessionsController {\n async index({ inertia, auth, session }: HttpContext) {\n const user = auth.getUserOrFail()\n return inertia.render('account/sessions', {\n sessions: await listUserSessions(user.id),\n currentSessionId: session.sessionId,\n })\n }\n\n /** Ending the current session signs the user out at once. */\n async destroy({ auth, params, response, session }: HttpContext) {\n const user = auth.getUserOrFail()\n const id = String(params.id)\n const sessions = await listUserSessions(user.id)\n if (!sessions.some((entry) => entry.id === id))\n throw new Exception('الجلسة غير موجودة', {\n status: 404,\n code: 'E_ROUTE_NOT_FOUND',\n })\n await revokeSession(id, user.id)\n if (id === session.sessionId) {\n await auth.use('web').logout()\n session.flash('success', 'أُنهيت جلستك الحالية. سجّل الدخول مجدداً عند الحاجة.')\n return response.redirect().toRoute('session.create')\n }\n session.flash('success', 'أُنهيت الجلسة.')\n return response.redirect().toRoute('account_sessions.index')\n }\n\n async purge({ auth, response, session }: HttpContext) {\n const user = auth.getUserOrFail()\n const count = await revokeUserSessions(user.id, user.id, { except: session.sessionId })\n session.flash('success', count ? `أُنهيت ${count} من الجلسات الأخرى.` : 'لا توجد جلسات أخرى.')\n return response.redirect().toRoute('account_sessions.index')\n }\n}\n","app/controllers/admin/activity_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { ActivityAdmin } from '@adula/kit'\nimport { knex, optionalId, text, wantsJson } from './support.js'\n\nexport default class ActivityController {\n async index(ctx: HttpContext) {\n const filters = {\n resource: text(ctx.request.input('resource')),\n action: text(ctx.request.input('action')),\n actorId: optionalId(ctx.request.input('actorId')) ?? undefined,\n from: text(ctx.request.input('from')),\n to: text(ctx.request.input('to')),\n }\n const service = new ActivityAdmin(knex())\n const activity = await service.list({\n ...filters,\n cursor: text(ctx.request.input('cursor')),\n limit: ctx.request.input('limit'),\n })\n if (wantsJson(ctx)) return activity\n return ctx.inertia.render('admin/activity/index', {\n activity,\n facets: await service.facets(),\n filters: {\n resource: filters.resource ?? '',\n action: filters.action ?? '',\n actorId: filters.actorId ? String(filters.actorId) : '',\n from: filters.from ?? '',\n to: filters.to ?? '',\n },\n })\n }\n}\n","app/controllers/admin/jobs_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport queue from '@nemoventures/adonis-jobs/services/main'\nimport { KitError, logActivity, runtimeHealth, type QueueSnapshot } from '@adula/kit'\nimport { actorId, knex, mutate, wantsJson } from './support.js'\n\n/** BullMQ stays in the application; the kit only defines the snapshot shape. */\nasync function snapshot(): Promise<QueueSnapshot> {\n const events = queue.useQueue('events')\n const counts = await events.getJobCounts('waiting', 'active', 'delayed', 'failed', 'completed')\n const failed = await events.getFailed(0, 49)\n return {\n name: 'events',\n counts: {\n waiting: counts.waiting ?? 0,\n active: counts.active ?? 0,\n delayed: counts.delayed ?? 0,\n failed: counts.failed ?? 0,\n completed: counts.completed ?? 0,\n },\n failed: failed.map((job) => ({\n id: String(job.id),\n name: job.name,\n attemptsMade: job.attemptsMade,\n failedReason: job.failedReason ?? '',\n failedAt: job.finishedOn ? new Date(job.finishedOn).toISOString() : null,\n })),\n }\n}\n\nexport default class JobsController {\n async index(ctx: HttpContext) {\n const props = { health: await runtimeHealth(knex()), queues: [await snapshot()] }\n if (wantsJson(ctx)) return props\n return ctx.inertia.render('admin/jobs/index', props)\n }\n\n async retry(ctx: HttpContext) {\n const id = String(ctx.params.id)\n if (!/^[\\w-]{1,128}$/.test(id)) throw new KitError(404, 'E_JOB_NOT_FOUND', 'الوظيفة غير موجودة')\n return mutate(\n ctx,\n async () => {\n const job = await queue.useQueue('events').getJob(id)\n if (!job) throw new KitError(404, 'E_JOB_NOT_FOUND', 'الوظيفة غير موجودة')\n if (!(await job.isFailed()))\n throw new KitError(422, 'E_JOB_NOT_FAILED', 'يمكن إعادة المحاولة للوظائف الفاشلة فقط')\n await job.retry()\n await logActivity(knex(), {\n resource: 'core.jobs',\n recordId: 0,\n actorId: actorId(ctx),\n action: 'retry',\n changes: { jobId: id, name: job.name },\n })\n return { id, state: await job.getState() }\n },\n 'أعيدت الوظيفة إلى الطابور'\n )\n }\n}\n","app/controllers/admin/notifications_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { NotificationsAdmin } from '@adula/kit'\nimport { actorId, knex, mutate, positiveId, text, wantsJson } from './support.js'\n\n/** Every signed-in user reads their own inbox; there is no administrative view of it. */\nexport default class NotificationsController {\n async index(ctx: HttpContext) {\n const notifications = await new NotificationsAdmin(knex()).list(actorId(ctx), {\n cursor: text(ctx.request.input('cursor')),\n limit: ctx.request.input('limit'),\n })\n if (wantsJson(ctx)) return notifications\n return ctx.inertia.render('admin/notifications/index', { notifications })\n }\n\n async read(ctx: HttpContext) {\n return mutate(\n ctx,\n () => new NotificationsAdmin(knex()).markRead(actorId(ctx), positiveId(ctx.params.id)),\n 'تم تعيين الإشعار كمقروء'\n )\n }\n\n async readAll(ctx: HttpContext) {\n return mutate(\n ctx,\n () => new NotificationsAdmin(knex()).markAllRead(actorId(ctx)),\n 'تم تعيين كل الإشعارات كمقروءة'\n )\n }\n}\n","app/controllers/admin/org_units_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { OrgUnitsAdmin } from '@adula/kit'\nimport { kit } from '#services/kit'\nimport { actorId, knex, mutate, optionalId, positiveId, wantsJson } from './support.js'\n\nconst service = () => new OrgUnitsAdmin(knex(), kit().registry)\n\nexport default class OrgUnitsController {\n async index(ctx: HttpContext) {\n const units = await service().tree()\n if (wantsJson(ctx)) return { data: units }\n return ctx.inertia.render('admin/org_units/index', { units })\n }\n\n async store(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().create(actorId(ctx), {\n parentId: optionalId(ctx.request.input('parentId')),\n name: ctx.request.input('name'),\n type: ctx.request.input('type'),\n }),\n 'تمت إضافة الوحدة'\n )\n }\n\n async update(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().rename(actorId(ctx), positiveId(ctx.params.id), ctx.request.input('name')),\n 'تمت إعادة التسمية'\n )\n }\n\n async move(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().move(\n actorId(ctx),\n positiveId(ctx.params.id),\n optionalId(ctx.request.input('parentId'))\n ),\n 'تم نقل الوحدة'\n )\n }\n\n async destroy(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().delete(actorId(ctx), positiveId(ctx.params.id)),\n 'تم حذف الوحدة'\n )\n }\n}\n","app/controllers/admin/roles_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { RolesAdmin } from '@adula/kit'\nimport { kit } from '#services/kit'\nimport { actorId, knex, mutate, positiveId, wantsJson } from './support.js'\n\nconst service = () => new RolesAdmin(knex(), kit().registry)\n\nexport default class RolesController {\n async index(ctx: HttpContext) {\n const roles = await service().list()\n if (wantsJson(ctx)) return { data: roles }\n return ctx.inertia.render('admin/roles/index', { roles })\n }\n\n async store(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().create(actorId(ctx), {\n name: ctx.request.input('name'),\n permissionLevel: ctx.request.input('permissionLevel'),\n }),\n 'تم إنشاء الدور'\n )\n }\n\n async show(ctx: HttpContext) {\n const role = await service().get(positiveId(ctx.params.id))\n const matrix = service().matrix()\n if (wantsJson(ctx)) return { data: role, matrix }\n return ctx.inertia.render('admin/roles/show', { role, matrix })\n }\n\n async update(ctx: HttpContext) {\n const id = positiveId(ctx.params.id)\n return mutate(\n ctx,\n async () => {\n const { name, permissionLevel } = ctx.request.only(['name', 'permissionLevel'])\n if (name !== undefined) await service().rename(actorId(ctx), id, name)\n if (permissionLevel !== undefined)\n await service().setPermissionLevel(actorId(ctx), id, Number(permissionLevel))\n },\n 'تم تحديث الدور'\n )\n }\n\n async destroy(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().delete(actorId(ctx), positiveId(ctx.params.id)),\n 'تم حذف الدور',\n '/admin/roles'\n )\n }\n\n async setRule(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().setRule(\n actorId(ctx),\n positiveId(ctx.params.id),\n ctx.request.only(['subject', 'action', 'inverted', 'conditions', 'fields'])\n ),\n 'تم حفظ القاعدة'\n )\n }\n\n async removeRule(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().removeRule(actorId(ctx), positiveId(ctx.params.id), positiveId(ctx.params.rule)),\n 'تم حذف القاعدة'\n )\n }\n}\n","app/controllers/admin/settings_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport {\n KitError,\n SETTING_SCOPES,\n SettingsAdmin,\n parseSettingValue,\n type SettingScope,\n} from '@adula/kit'\nimport { actorId, knex, mutate, positiveId, text, wantsJson } from './support.js'\nimport {\n mailTest,\n mailFingerprint,\n sendMailTest,\n publicMailTest,\n} from '#services/mail_delivery_test'\n\nfunction scopeOf(value: unknown): SettingScope {\n const scope = text(value) ?? 'system'\n if (!SETTING_SCOPES.includes(scope as SettingScope))\n throw new KitError(422, 'E_SETTING_SCOPE', 'النطاق غير معروف')\n return scope as SettingScope\n}\n\nexport default class SettingsController {\n async index(ctx: HttpContext) {\n const scope = scopeOf(ctx.request.input('scope'))\n const scopeId = scope === 'system' ? '0' : (text(ctx.request.input('scopeId')) ?? '')\n const settings =\n scope === 'system' || /^\\d{1,18}$/.test(scopeId)\n ? await new SettingsAdmin(knex()).list(scope, scopeId)\n : []\n if (wantsJson(ctx)) return { data: settings, scope, scopeId }\n const user = ctx.auth.getUserOrFail()\n const state = await mailTest().current(user.id, user.email, mailFingerprint())\n return ctx.inertia.render('admin/settings/index', {\n settings,\n scope,\n scopeId,\n mailTest: publicMailTest(state),\n mailRecipient: user.email,\n })\n }\n\n async testMail(ctx: HttpContext) {\n const user = ctx.auth.getUserOrFail()\n return mutate(\n ctx,\n () =>\n mailTest().send(user.id, user.email, mailFingerprint(), (id) =>\n sendMailTest(user.email, id)\n ),\n 'قُبل طلب الإرسال. تحقق من بريدك وأكد وصول الرسالة.',\n ctx.request.input('returnTo') === 'setup' ? '/admin/setup' : '/admin/settings'\n )\n }\n\n async confirmMail(ctx: HttpContext) {\n const user = ctx.auth.getUserOrFail()\n return mutate(\n ctx,\n () =>\n mailTest().answer(\n user.id,\n user.email,\n mailFingerprint(),\n ctx.request.input('id'),\n ctx.request.input('received')\n ),\n ctx.request.input('received') === true\n ? 'تم تسجيل تأكيدك بوصول الرسالة'\n : 'تم تسجيل عدم وصول الرسالة. راجع البريد غير المرغوب وإعدادات البريد.',\n ctx.request.input('returnTo') === 'setup' ? '/admin/setup' : '/admin/settings'\n )\n }\n\n async upsert(ctx: HttpContext) {\n return mutate(\n ctx,\n () => {\n const raw = ctx.request.input('value')\n return new SettingsAdmin(knex()).upsert(actorId(ctx), {\n key: ctx.request.input('key'),\n scope: scopeOf(ctx.request.input('scope')),\n scopeId: text(ctx.request.input('scopeId')),\n value: typeof raw === 'string' ? parseSettingValue(raw) : raw,\n })\n },\n 'تم حفظ الإعداد'\n )\n }\n\n async destroy(ctx: HttpContext) {\n return mutate(\n ctx,\n () => new SettingsAdmin(knex()).delete(actorId(ctx), positiveId(ctx.params.id)),\n 'تم حذف الإعداد'\n )\n }\n}\n","app/controllers/admin/setup_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { KitError } from '@adula/kit'\nimport { actorId, mutate, wantsJson } from './support.js'\nimport {\n setup,\n setupSnapshot,\n identityFingerprint,\n storageFingerprint,\n infrastructureFingerprint,\n probeStorage,\n probeInfrastructure,\n} from '#services/initial_setup'\n\nexport default class SetupController {\n async index(ctx: HttpContext) {\n const state = await setupSnapshot(ctx.auth.getUserOrFail())\n if (wantsJson(ctx)) return state\n return ctx.inertia.render('admin/setup/index', state)\n }\n async check(ctx: HttpContext) {\n return mutate(\n ctx,\n async () => {\n const name = ctx.params.service\n if (name !== 'storage' && name !== 'infrastructure')\n throw new KitError(422, 'E_SETUP_SERVICE', 'الخدمة غير معروفة')\n await setup().check(\n actorId(ctx),\n name,\n name === 'storage' ? storageFingerprint() : infrastructureFingerprint(),\n name === 'storage' ? probeStorage : probeInfrastructure\n )\n },\n 'نجح فحص الخدمة',\n '/admin/setup'\n )\n }\n async confirmIdentity(ctx: HttpContext) {\n return mutate(\n ctx,\n async () => {\n if (ctx.request.input('confirmed') !== true)\n throw new KitError(422, 'E_SETUP_CONFIRM', 'تأكيد الهوية مطلوب')\n await setup().acknowledgeIdentity(actorId(ctx), await identityFingerprint())\n },\n 'تم اعتماد الهوية الحالية',\n '/admin/setup'\n )\n }\n async notification(ctx: HttpContext) {\n return mutate(\n ctx,\n () => setup().testNotification(actorId(ctx)),\n 'أُنشئ الإشعار التجريبي. افتح الإشعارات وحدده كمقروء.',\n '/admin/setup'\n )\n }\n}\n","app/controllers/admin/support.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport db from '@adonisjs/lucid/services/db'\nimport { KitError } from '@adula/kit'\n\nexport const knex = () => db.connection().getWriteClient()\nexport const actorId = (ctx: HttpContext) => ctx.auth.getUserOrFail().id\nexport const wantsJson = (ctx: HttpContext) => ctx.request.accepts(['html', 'json']) === 'json'\nexport const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined)\n\nexport function positiveId(value: unknown, message = 'السجل غير موجود') {\n const id = Number(value)\n if (!Number.isSafeInteger(id) || id <= 0) throw new KitError(404, 'E_NOT_FOUND', message)\n return id\n}\nexport function optionalId(value: unknown) {\n if (value === undefined || value === null || value === '') return null\n return positiveId(value, 'المعرّف غير صالح')\n}\n\n/** API clients get JSON; Inertia forms get a flash and a redirect so the page re-renders. */\nexport async function mutate(\n ctx: HttpContext,\n run: () => Promise<unknown>,\n success: string,\n redirectTo?: string\n) {\n try {\n const data = await run()\n if (wantsJson(ctx)) return { data: data ?? true }\n ctx.session.flash('success', success)\n return redirectTo ? ctx.response.redirect(redirectTo) : ctx.response.redirect().back()\n } catch (error) {\n if (wantsJson(ctx)) throw error\n const code = (error as { code?: string })?.code\n const message =\n error instanceof KitError\n ? error.message\n : code === '23505'\n ? 'هذه القيمة مستخدمة في سجل آخر'\n : code === '23503'\n ? 'تحقق من السجلات المرتبطة'\n : undefined\n if (!message) throw error\n ctx.session.flash('error', message)\n return ctx.response.redirect().back()\n }\n}\n","app/controllers/admin/users_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { KitError, OrgUnitsAdmin, RolesAdmin, UsersAdmin, logActivity } from '@adula/kit'\nimport User from '#models/user'\nimport { kit } from '#services/kit'\nimport { revokeUserSessions } from '#services/sessions'\nimport { IMPERSONATOR_KEY } from '#middleware/admin_middleware'\nimport { actorId, knex, mutate, optionalId, positiveId, text, wantsJson } from './support.js'\n\nconst service = () => new UsersAdmin(knex())\nconst RESOURCE = 'core.users'\n\nexport default class UsersController {\n async index(ctx: HttpContext) {\n const search = text(ctx.request.input('search')) ?? ''\n const users = await service().list({\n search,\n cursor: optionalId(ctx.request.input('cursor')) ?? undefined,\n limit: ctx.request.input('limit'),\n })\n if (wantsJson(ctx)) return users\n return ctx.inertia.render('admin/users/index', { users, search })\n }\n\n async show(ctx: HttpContext) {\n const user = await service().get(positiveId(ctx.params.id))\n const roles = await new RolesAdmin(knex(), kit().registry).list()\n const orgUnits = await new OrgUnitsAdmin(knex(), kit().registry).tree()\n const props = { user, roles, orgUnits }\n if (wantsJson(ctx)) return props\n return ctx.inertia.render('admin/users/show', props)\n }\n\n async assignRole(ctx: HttpContext) {\n const id = positiveId(ctx.params.id)\n return mutate(\n ctx,\n () =>\n service().assignRole(\n actorId(ctx),\n id,\n positiveId(ctx.request.input('roleId'), 'الدور غير موجود'),\n optionalId(ctx.request.input('orgUnitId'))\n ),\n 'تم إسناد الدور'\n )\n }\n\n async removeRole(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().removeRole(\n actorId(ctx),\n positiveId(ctx.params.id),\n positiveId(ctx.params.assignment)\n ),\n 'تمت إزالة الدور'\n )\n }\n\n async assignOrgUnit(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().assignOrgUnit(\n actorId(ctx),\n positiveId(ctx.params.id),\n positiveId(ctx.request.input('orgUnitId'), 'الوحدة التنظيمية غير موجودة')\n ),\n 'تمت إضافة المستخدم إلى الوحدة'\n )\n }\n\n async removeOrgUnit(ctx: HttpContext) {\n return mutate(\n ctx,\n () =>\n service().removeOrgUnit(\n actorId(ctx),\n positiveId(ctx.params.id),\n positiveId(ctx.params.orgUnit)\n ),\n 'تمت إزالة العضوية'\n )\n }\n\n async disable(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().disable(actorId(ctx), positiveId(ctx.params.id)),\n 'تم تعطيل الحساب'\n )\n }\n\n async enable(ctx: HttpContext) {\n return mutate(\n ctx,\n () => service().enable(actorId(ctx), positiveId(ctx.params.id)),\n 'تم تفعيل الحساب'\n )\n }\n\n async revokeSessions(ctx: HttpContext) {\n const id = positiveId(ctx.params.id)\n const admin = actorId(ctx)\n return mutate(\n ctx,\n async () => {\n await service().get(id)\n const count = await revokeUserSessions(id, admin, {\n except: id === admin ? ctx.session.sessionId : undefined,\n })\n await logActivity(knex(), {\n resource: RESOURCE,\n recordId: id,\n actorId: admin,\n action: 'revoke_sessions',\n changes: { count },\n })\n return { count }\n },\n 'تم إنهاء جلسات المستخدم'\n )\n }\n\n /** The administrator keeps their identity in the session so the target user cannot inherit it. */\n async impersonate(ctx: HttpContext) {\n const id = positiveId(ctx.params.id)\n const admin = actorId(ctx)\n return mutate(\n ctx,\n async () => {\n if (id === admin) throw new KitError(422, 'E_SELF_IMPERSONATE', 'لا يمكنك انتحال حسابك')\n if (ctx.session.get(IMPERSONATOR_KEY))\n throw new KitError(422, 'E_ALREADY_IMPERSONATING', 'أنهِ الانتحال الحالي أولاً')\n const target = await service().get(id)\n if (target.disabledAt)\n throw new KitError(422, 'E_USER_DISABLED', 'لا يمكن انتحال حساب معطّل')\n const user = await User.findOrFail(id)\n await logActivity(knex(), {\n resource: RESOURCE,\n recordId: id,\n actorId: admin,\n action: 'impersonate',\n })\n ctx.session.put(IMPERSONATOR_KEY, admin)\n await ctx.auth.use('web').login(user)\n return { id }\n },\n 'أنت الآن تتصفح باسم المستخدم',\n '/'\n )\n }\n\n async stopImpersonation(ctx: HttpContext) {\n const current = actorId(ctx)\n return mutate(\n ctx,\n async () => {\n const impersonator = Number(ctx.session.get(IMPERSONATOR_KEY))\n if (!impersonator) throw new KitError(422, 'E_NOT_IMPERSONATING', 'لا يوجد انتحال نشط')\n const original = await User.find(impersonator)\n if (!original) throw new KitError(404, 'E_USER_NOT_FOUND', 'المستخدم غير موجود')\n ctx.session.forget(IMPERSONATOR_KEY)\n await ctx.auth.use('web').login(original)\n await logActivity(knex(), {\n resource: RESOURCE,\n recordId: current,\n actorId: impersonator,\n action: 'stop_impersonation',\n })\n return { id: impersonator }\n },\n 'عدت إلى حسابك',\n `/admin/users/${current}`\n )\n }\n}\n","app/controllers/admin_sessions_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { Exception } from '@adonisjs/core/exceptions'\nimport { buildAbility } from '@adula/kit'\nimport { kit } from '#services/kit'\nimport { listActiveSessions, revokeSession } from '#services/sessions'\n\n/** Every live session across users; only actors who can manage everything. */\nexport default class AdminSessionsController {\n async index(ctx: HttpContext) {\n await this.authorize(ctx)\n return ctx.inertia.render('admin/sessions/index', {\n sessions: await listActiveSessions(),\n currentSessionId: ctx.session.sessionId,\n })\n }\n\n async destroy(ctx: HttpContext) {\n const actor = await this.authorize(ctx)\n const revoked = await revokeSession(String(ctx.params.id), actor.id)\n if (revoked) ctx.session.flash('success', 'أُنهيت الجلسة وسيُطلب من صاحبها الدخول مجدداً.')\n else ctx.session.flash('error', 'الجلسة غير موجودة أو أُنهيت من قبل.')\n return ctx.response.redirect().toRoute('admin_sessions.index')\n }\n\n private async authorize({ auth }: HttpContext) {\n const user = auth.getUserOrFail()\n const actor = await kit().actors.load(user.id)\n const ability = buildAbility(actor.rules, kit().registry.all())\n if (!ability.can('manage', 'all'))\n throw new Exception('غير مصرح لك بالوصول إلى هذه الصفحة', {\n status: 403,\n code: 'E_FORBIDDEN',\n })\n return user\n }\n}\n","app/controllers/attachments_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { randomUUID } from 'node:crypto'\nimport attachmentManager from '@jrmc/adonis-attachment/services/main'\nimport drive from '@adonisjs/drive/services/main'\nimport db from '@adonisjs/lucid/services/db'\nimport env from '#start/env'\nimport { kit } from '#services/kit'\nimport { KitError, attachmentUrl, buildAbility, findAttachment, registerUpload } from '@adula/kit'\nimport type { Actor, AttachmentRecord } from '@adula/kit'\n\nconst notFound = () => new KitError(404, 'E_NOT_FOUND', 'المرفق غير موجود')\n\n/** RFC 5987: an ASCII fallback plus the UTF-8 name so Arabic titles survive every browser. */\nfunction contentDisposition(name: string) {\n const fallback = name.replace(/[^\\x20-\\x7e]/g, '_').replace(/[\"\\\\]/g, '_') || 'file'\n const encoded = encodeURIComponent(name).replace(\n /[!'()*]/g,\n (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`\n )\n return `attachment; filename=\"${fallback}\"; filename*=UTF-8''${encoded}`\n}\n\nexport default class AttachmentsController {\n async store({ auth, request, response }: HttpContext) {\n const runtime = kit()\n const actor = await runtime.actors.load(auth.getUserOrFail().id)\n const resourceName = request.input('resource')\n const fieldName = request.input('field')\n if (\n typeof resourceName !== 'string' ||\n !runtime.registry.all().some((entry) => entry.name === resourceName)\n )\n throw new KitError(404, 'E_NOT_FOUND', 'الكيان غير موجود')\n const resource = runtime.registry.get(resourceName)\n const field = typeof fieldName === 'string' ? resource.fields[fieldName] : undefined\n if (!field || field.type !== 'attachment' || !resource.form.includes(fieldName))\n throw new KitError(422, 'E_FIELD_INVALID', 'الحقل ليس حقل مرفقات')\n const ability = buildAbility(actor.rules, runtime.registry.all())\n const level = Math.max(field.permissionLevel ?? 0, resource.hidden?.includes(fieldName) ? 1 : 0)\n const allowed =\n actor.permissionLevel >= level &&\n (['create', 'update'] as const).some(\n (action) =>\n resource.actions.includes(action) &&\n ability.can(action, resource.name) &&\n ability.can(action, resource.name, fieldName)\n )\n if (!allowed) throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لرفع مرفق لهذا الحقل')\n const file = request.file('file', { size: '20mb' })\n if (!file) throw new KitError(422, 'E_FILE_REQUIRED', 'اختر ملفاً للرفع')\n if (!file.isValid)\n throw new KitError(\n 422,\n 'E_FILE_INVALID',\n file.errors.map((error) => error.message).join('، ')\n )\n const extname = (file.extname ?? '').toLowerCase()\n const safeExtname = /^[a-z0-9]{1,32}$/.test(extname) ? extname : 'bin'\n const originalName =\n (file.clientName.split(/[\\\\/]/).pop() || '').slice(0, 255) || `file.${safeExtname}`\n const disk = env.get('DRIVE_DISK')\n const attachment = await attachmentManager.createFromFile(file)\n attachment.name = `${randomUUID()}.${safeExtname}`\n attachment.setOptions({ folder: `resources/${resource.name}/${fieldName}`, disk })\n await attachmentManager.write(attachment)\n const path = (attachment.path ?? '').replaceAll('\\\\', '/')\n try {\n const row = await registerUpload(db.connection().getWriteClient(), {\n disk,\n path,\n name: attachment.name,\n originalName,\n size: attachment.size,\n mimeType: attachment.mimeType || 'application/octet-stream',\n extname: safeExtname,\n data: { ...attachment.toObject(), path },\n uploadedBy: actor.id,\n resource: resource.name,\n field: fieldName,\n })\n return response.created({\n data: {\n id: row.id,\n name: row.originalName,\n size: row.size,\n mimeType: row.mimeType,\n url: attachmentUrl(row.id),\n },\n })\n } catch (error) {\n await attachmentManager.remove(attachment)\n throw error\n }\n }\n\n async show({ auth, params, response }: HttpContext) {\n const runtime = kit()\n const actor = await runtime.actors.load(auth.getUserOrFail().id)\n const id = Number(params.id)\n const row = await findAttachment(\n db.connection().getWriteClient(),\n /^\\d+$/.test(String(params.id)) ? id : undefined\n )\n if (!row) throw notFound()\n await this.#authorize(row, actor, runtime)\n const disk = drive.use(row.disk as never)\n if (!(await disk.exists(row.path)))\n throw new KitError(500, 'E_ATTACHMENT_FILE_MISSING', 'ملف المرفق غير متاح على وحدة التخزين')\n response.header('Content-Type', row.mimeType)\n response.header('Content-Length', String(row.size))\n response.header('Content-Disposition', contentDisposition(row.originalName))\n response.header('X-Content-Type-Options', 'nosniff')\n response.header('Cache-Control', 'private, no-store')\n return response.stream(await disk.getStream(row.path))\n }\n\n /** Bound files inherit the record's view policy; unbound uploads belong to their uploader only. */\n async #authorize(row: AttachmentRecord, actor: Actor, runtime: ReturnType<typeof kit>) {\n if (row.recordId === null || row.resource === null || row.field === null) {\n if (row.uploadedBy !== actor.id) throw notFound()\n return\n }\n if (!runtime.registry.all().some((entry) => entry.name === row.resource)) throw notFound()\n let shown\n try {\n shown = await runtime.resources.show(row.resource, row.recordId, actor)\n } catch (error) {\n if (error instanceof KitError && [403, 404].includes(error.status)) throw notFound()\n throw error\n }\n const value = shown.data[row.field]\n if (\n !value ||\n typeof value !== 'object' ||\n Array.isArray(value) ||\n Number((value as { id?: unknown }).id) !== row.id\n )\n throw notFound()\n }\n}\n","app/controllers/new_account_controller.ts":"import User from '#models/user'\nimport { signupValidator } from '#validators/user'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { recordSession } from '#services/sessions'\n\nexport default class NewAccountController {\n async create({ inertia }: HttpContext) {\n return inertia.render('auth/signup', {})\n }\n\n async store(ctx: HttpContext) {\n const { request, response, auth } = ctx\n const { passwordConfirmation, ...payload } = await request.validateUsing(signupValidator)\n const user = await User.create({ ...payload })\n\n await auth.use('web').login(user)\n await recordSession(ctx, user.id)\n await logAuthActivity({\n userId: user.id,\n action: 'login',\n changes: { ...requestContext(ctx), via: 'signup' },\n })\n response.redirect().toRoute('home')\n }\n}\n","app/controllers/oauth_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { Exception } from '@adonisjs/core/exceptions'\nimport { isSocialProvider, linkOrCreateSocialUser } from '#services/social_accounts'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { recordSession } from '#services/sessions'\nimport { Settings } from '@adula/kit'\nimport db from '@adonisjs/lucid/services/db'\nimport { oauthFingerprint } from '#services/initial_setup'\n\n/** Unknown or unconfigured providers behave like a missing route. */\nconst notFound = () =>\n new Exception('الصفحة غير موجودة', { status: 404, code: 'E_ROUTE_NOT_FOUND' })\n\nexport default class OauthController {\n async redirect({ params, ally }: HttpContext) {\n const provider: unknown = params.provider\n if (!isSocialProvider(provider)) throw notFound()\n return ally.use(provider).redirect()\n }\n\n async callback(ctx: HttpContext) {\n const { params, ally, auth, response, session } = ctx\n const provider: unknown = params.provider\n if (!isSocialProvider(provider)) throw notFound()\n const driver = ally.use(provider)\n const fail = (message: string) => {\n session.flash('error', message)\n return response.redirect().toRoute('session.create')\n }\n if (driver.accessDenied()) return fail('ألغيت تسجيل الدخول قبل منح الإذن.')\n if (driver.stateMisMatch()) return fail('انتهت صلاحية طلب تسجيل الدخول. حاول مجدداً.')\n if (driver.hasError()) return fail(`تعذّر تسجيل الدخول عبر المزوّد: ${driver.getError()}`)\n\n const profile = await driver.user()\n if (!profile.email || profile.emailVerificationState !== 'verified')\n return fail('يتطلب الدخول بريداً إلكترونياً موثقاً لدى المزوّد.')\n const { user, created } = await linkOrCreateSocialUser({\n provider,\n providerId: String(profile.id),\n email: profile.email,\n name: profile.name || profile.nickName || null,\n })\n if (user.disabledAt) return fail('هذا الحساب معطّل. تواصل مع مدير النظام.')\n\n await auth.use('web').login(user)\n await recordSession(ctx, user.id)\n await new Settings(db.connection().getWriteClient()).set(`setup.oauth.${provider}`, {\n fingerprint: oauthFingerprint(provider),\n at: new Date().toISOString(),\n })\n await logAuthActivity({\n userId: user.id,\n action: 'oauth_login',\n changes: { ...requestContext(ctx), provider, created },\n })\n return response.redirect().toRoute('home')\n }\n}\n","app/controllers/password_reset_controller.ts":"import { createHash, randomBytes } from 'node:crypto'\nimport env from '#start/env'\nimport User from '#models/user'\nimport db from '@adonisjs/lucid/services/db'\nimport mail from '@adonisjs/mail/services/main'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport PasswordResetNotification from '#mails/password_reset_notification'\nimport { forgotPasswordValidator, resetPasswordValidator } from '#validators/user'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { revokeUserSessions } from '#services/sessions'\n\n/** Recovery links expire after one hour and can be used once. */\nconst TOKEN_TTL_MS = 60 * 60 * 1000\nconst hashToken = (token: string) => createHash('sha256').update(token).digest('hex')\nconst knex = () => db.connection().getWriteClient()\n\n/** The pending token row, or null when unknown, already used or expired. */\nasync function pendingToken(token: unknown) {\n if (typeof token !== 'string' || token.length < 16 || token.length > 128) return null\n const row = await knex()('password_reset_tokens')\n .where({ token_hash: hashToken(token) })\n .first()\n if (!row || row.used_at || new Date(row.expires_at).getTime() < Date.now()) return null\n return row\n}\n\nexport default class PasswordResetController {\n async forgot({ inertia }: HttpContext) {\n return inertia.render('auth/forgot', {})\n }\n\n /** Always answers the same way so the form cannot be used to probe e-mails. */\n async send(ctx: HttpContext) {\n const { request, response, session } = ctx\n const { email } = await request.validateUsing(forgotPasswordValidator)\n const user = await User.query().whereRaw('lower(email) = lower(?)', [email]).first()\n if (user && !user.disabledAt) {\n const token = randomBytes(32).toString('base64url')\n await knex()('password_reset_tokens')\n .where({ user_id: user.id })\n .whereNull('used_at')\n .delete()\n await knex()('password_reset_tokens').insert({\n user_id: user.id,\n token_hash: hashToken(token),\n expires_at: new Date(Date.now() + TOKEN_TTL_MS),\n })\n let accepted = false\n try {\n await mail.send(\n new PasswordResetNotification(user, `${env.get('APP_URL')}/password/reset/${token}`)\n )\n accepted = true\n } catch {\n // Revoke only this attempt, preserving any newer concurrent request.\n await knex()('password_reset_tokens')\n .where({ token_hash: hashToken(token) })\n .delete()\n }\n await logAuthActivity({\n userId: user.id,\n action: accepted ? 'password_reset_requested' : 'password_reset_delivery_failed',\n changes: requestContext(ctx),\n })\n }\n session.flash(\n 'success',\n 'إن كان البريد مسجلاً لدينا فستصلك رسالة تحوي رابط إعادة التعيين خلال دقائق.'\n )\n return response.redirect().toRoute('password_reset.forgot')\n }\n\n async reset({ inertia, params }: HttpContext) {\n const token = String(params.token)\n return inertia.render('auth/reset', { token, valid: Boolean(await pendingToken(token)) })\n }\n\n async update(ctx: HttpContext) {\n const { request, response, session, params } = ctx\n const { password } = await request.validateUsing(resetPasswordValidator)\n const row = await pendingToken(params.token)\n const user = row ? await User.find(row.user_id) : null\n // The conditional update makes the token single-use even under concurrent submits.\n const consumed = row\n ? await knex()('password_reset_tokens')\n .where({ id: row.id })\n .whereNull('used_at')\n .update({ used_at: knex().fn.now() })\n : 0\n if (!row || !user || user.disabledAt || !consumed) {\n session.flash('error', 'رابط إعادة التعيين غير صالح أو انتهت صلاحيته. اطلب رابطاً جديداً.')\n return response.redirect().toRoute('password_reset.forgot')\n }\n\n user.password = password\n await user.save()\n await revokeUserSessions(user.id, user.id)\n await logAuthActivity({\n userId: user.id,\n action: 'password_reset',\n changes: requestContext(ctx),\n })\n session.flash('success', 'تم تغيير كلمة المرور. سجّل الدخول بكلمة المرور الجديدة.')\n return response.redirect().toRoute('session.create')\n }\n}\n","app/controllers/profile_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { changePasswordValidator, profileValidator } from '#validators/user'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { revokeUserSessions } from '#services/sessions'\n\nexport default class ProfileController {\n async show({ inertia }: HttpContext) {\n return inertia.render('account/profile', {})\n }\n\n async update(ctx: HttpContext) {\n const { request, auth, response, session } = ctx\n const { fullName } = await request.validateUsing(profileValidator)\n const user = auth.getUserOrFail()\n const previous = user.fullName\n user.fullName = fullName\n await user.save()\n await logAuthActivity({\n userId: user.id,\n action: 'profile_updated',\n changes: { ...requestContext(ctx), fields: ['fullName'], previous: { fullName: previous } },\n })\n session.flash('success', 'تم حفظ بيانات الملف الشخصي.')\n return response.redirect().toRoute('profile.show')\n }\n\n /** Changing the password ends every other session of the user. */\n async password(ctx: HttpContext) {\n const { request, auth, response, session } = ctx\n const { currentPassword, password } = await request.validateUsing(changePasswordValidator)\n const user = auth.getUserOrFail()\n if (!(await user.verifyPassword(currentPassword))) {\n session.flash('inputErrorsBag', { currentPassword: ['كلمة المرور الحالية غير صحيحة'] })\n return response.redirect().toRoute('profile.show')\n }\n user.password = password\n await user.save()\n await revokeUserSessions(user.id, user.id, { except: session.sessionId })\n await logAuthActivity({\n userId: user.id,\n action: 'password_changed',\n changes: requestContext(ctx),\n })\n session.flash('success', 'تم تغيير كلمة المرور وإنهاء الجلسات الأخرى.')\n return response.redirect().toRoute('profile.show')\n }\n}\n","app/controllers/resources_controller.ts":"import { existsSync, readFileSync, readdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport app from '@adonisjs/core/services/app'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { createResourceController } from '@adula/kit'\nimport type { Actor, ResourceDescription } from '@adula/kit'\nimport { kit } from '#services/kit'\n\ntype Mode = 'index' | 'form' | 'show'\nconst modes: Mode[] = ['index', 'form', 'show']\n\n/**\n * Presence of `inertia/pages/<resource>/<mode>.tsx` replaces the generated page.\n * Scanned once at boot: sources in development, the Vite manifest in production builds.\n */\nfunction discoverPageOverrides() {\n const found = new Set<string>()\n const roots = [app.makePath('inertia/pages')]\n for (const pages of roots) {\n if (!existsSync(pages)) continue\n for (const entry of readdirSync(pages, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue\n for (const mode of modes)\n if (existsSync(join(pages, entry.name, `${mode}.tsx`))) found.add(`${entry.name}/${mode}`)\n }\n }\n if (roots.some((path) => existsSync(path))) return found\n const manifest = app.publicPath('assets/.vite/manifest.json')\n if (!existsSync(manifest)) return found\n for (const key of Object.keys(JSON.parse(readFileSync(manifest, 'utf8')))) {\n const match = /^inertia\\/pages\\/([^/]+)\\/(index|form|show)\\.tsx$/.exec(key)\n if (match) found.add(`${match[1]}/${match[2]}`)\n }\n return found\n}\nconst overrides = discoverPageOverrides()\nexport function pageFor(resource: string, mode: Mode) {\n return overrides.has(`${resource}/${mode}`) ? `${resource}/${mode}` : 'resources/page'\n}\n/** Override pages receive the generic payload; the cast only widens the page name resolved at boot. */\nconst generic = (page: string) => page as 'resources/page'\n\nasync function actorOf(ctx: HttpContext) {\n return kit().actors.load(ctx.auth.getUserOrFail().id)\n}\n\nfunction childDescriptions(description: ResourceDescription, actor: Actor) {\n const children: Record<string, ResourceDescription> = {}\n for (const field of description.fields) {\n if (field.type !== 'hasMany') continue\n try {\n children[field.key] = kit().resources.describe(field.resource, actor)\n } catch {\n // A child the actor cannot view is omitted; the deferred rows are authorized separately.\n }\n }\n return children\n}\n\nexport default createResourceController(\n async (ctx) => {\n const runtime = kit()\n return { ...runtime, actor: await runtime.actors.load(ctx.auth.getUserOrFail().id) }\n },\n async (ctx, resource, result) => {\n const runtime = kit()\n const actor = await actorOf(ctx)\n const page = pageFor(resource.name, 'index')\n return ctx.inertia.render(generic(page), {\n view: async () => ({\n mode: 'index' as const,\n resource: runtime.resources.describe(resource.name, actor),\n lookups: await runtime.resources.lookups(resource.name, actor),\n savedViews: await runtime.savedViews.list(resource.name, actor),\n }),\n result: ctx.inertia\n .scroll(result, (value) => ({\n pageName: 'cursor',\n currentPage: (ctx.request.input('cursor') as string | undefined) ?? null,\n nextPage: value.meta.nextCursor,\n previousPage: null,\n }))\n .matchOn('id'),\n })\n },\n async (ctx, resource, editor) => {\n const page = pageFor(resource.name, 'form')\n const actor = await actorOf(ctx)\n const shown = editor.record\n ? await kit().resources.show(resource.name, Number(editor.record.id), actor)\n : null\n const permissions = shown ? shown.permissions : {}\n return ctx.inertia.render(generic(page), {\n view: { mode: 'form', editor, permissions },\n })\n },\n async (ctx, resource, result) => {\n const runtime = kit()\n const actor = await actorOf(ctx)\n const id = Number(result.data.id)\n const description = runtime.resources.describe(resource.name, actor)\n return ctx.inertia.render(generic(pageFor(resource.name, 'show')), {\n view: async () => ({\n mode: 'show' as const,\n resource: description,\n result,\n lookups: await runtime.resources.lookups(resource.name, actor),\n childResources: childDescriptions(description, actor),\n }),\n childrenData: ctx.inertia.defer(\n () => runtime.resources.children(resource.name, id, actor),\n 'children'\n ),\n activity: ctx.inertia.defer(\n () => runtime.resources.activity(resource.name, id, actor),\n 'activity'\n ),\n })\n }\n)\n","app/controllers/saved_views_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { KitError, type Actor as KitActor } from '@adula/kit'\nimport { kit } from '#services/kit'\n\n/** Saved views are per-actor list presets; the kit validates every query key. */\nexport default class SavedViewsController {\n async store(ctx: HttpContext) {\n return this.#run(ctx, async (actor, resource) => ({\n data: await kit().savedViews.save(resource, actor, {\n name: ctx.request.input('name'),\n query: ctx.request.input('query'),\n shared: ctx.request.input('shared') === true,\n }),\n }))\n }\n\n async destroy(ctx: HttpContext) {\n return this.#run(ctx, async (actor, resource) => {\n const id = Number(ctx.params.id)\n if (!Number.isSafeInteger(id) || id <= 0)\n throw new KitError(404, 'E_VIEW_NOT_FOUND', 'العرض المحفوظ غير موجود')\n await kit().savedViews.remove(resource, actor, id)\n return { data: { id } }\n })\n }\n\n async #run(ctx: HttpContext, action: (actor: KitActor, resource: string) => Promise<unknown>) {\n try {\n const actor = await kit().actors.load(ctx.auth.getUserOrFail().id)\n return await action(actor, ctx.params.resource)\n } catch (error) {\n if (error instanceof KitError)\n return ctx.response\n .status(error.status)\n .send({ error: { code: error.code, message: error.message } })\n throw error\n }\n }\n}\n","app/controllers/session_controller.ts":"import User from '#models/user'\nimport { loginValidator } from '#validators/user'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { errors as authErrors } from '@adonisjs/auth'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\nimport { endSession, recordSession } from '#services/sessions'\nimport { socialProviders } from '#services/social_accounts'\n\nexport default class SessionController {\n async create({ inertia }: HttpContext) {\n return inertia.render('auth/login', { socialProviders: socialProviders() })\n }\n\n async store(ctx: HttpContext) {\n const { request, auth, response } = ctx\n const { email, password } = await request.validateUsing(loginValidator)\n let user: User\n try {\n user = await User.verifyCredentials(email, password)\n } catch (error) {\n if (!(error instanceof authErrors.E_INVALID_CREDENTIALS)) throw error\n // Unknown e-mails cannot be logged (activities reference users); the limiter counts them.\n const known = await User.findBy('email', email)\n if (known)\n await logAuthActivity({\n userId: known.id,\n action: 'login_failed',\n changes: requestContext(ctx),\n })\n return this.refuse(ctx, 'البريد الإلكتروني أو كلمة المرور غير صحيحة')\n }\n if (user.disabledAt) {\n await logAuthActivity({\n userId: user.id,\n action: 'login_failed',\n changes: { ...requestContext(ctx), reason: 'disabled' },\n })\n return this.refuse(ctx, 'هذا الحساب معطّل. تواصل مع مدير النظام.')\n }\n\n await auth.use('web').login(user)\n await recordSession(ctx, user.id)\n await logAuthActivity({ userId: user.id, action: 'login', changes: requestContext(ctx) })\n response.redirect().toRoute('home')\n }\n\n async destroy(ctx: HttpContext) {\n const { auth, response, session } = ctx\n const user = auth.getUserOrFail()\n const sessionId = session.sessionId\n await auth.use('web').logout()\n await endSession(sessionId)\n await logAuthActivity({ userId: user.id, action: 'logout', changes: requestContext(ctx) })\n response.redirect().toRoute('session.create')\n }\n\n private refuse({ request, response, session }: HttpContext, message: string) {\n if (request.accepts(['html', 'json']) === 'json')\n return response.unauthorized({ errors: [{ message }] })\n session.flashExcept(['password'])\n session.flash('inputErrorsBag', { email: [message] })\n return response.redirect().toRoute('session.create')\n }\n}\n","app/controllers/user_invitations_controller.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { KitError, UserInvitations } from '@adula/kit'\nimport db from '@adonisjs/lucid/services/db'\nimport hash from '@adonisjs/core/services/hash'\nimport mail from '@adonisjs/mail/services/main'\nimport env from '#start/env'\nimport { ValidationError } from '@vinejs/vine'\nimport UserInvitationNotification from '#mails/user_invitation_notification'\nimport { invitationValidator, acceptInvitationValidator } from '#validators/user'\n\nconst service = () => new UserInvitations(db.connection().getWriteClient())\n\nexport default class UserInvitationsController {\n async create(ctx: HttpContext) {\n if (!(await service().canInvite(ctx.auth.getUserOrFail().id)))\n throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية دعوة المستخدمين')\n return ctx.inertia.render('users/invite', {})\n }\n\n async store(ctx: HttpContext) {\n const id = ctx.auth.getUserOrFail().id\n if (!(await service().canInvite(id)))\n throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية دعوة المستخدمين')\n try {\n const input = await ctx.request.validateUsing(invitationValidator)\n const data = await service().invite(id, input, async ({ email, token }) => {\n await mail.send(\n new UserInvitationNotification(\n email,\n `${env.get('APP_URL').replace(/\\/$/, '')}/invitations/${token}`\n )\n )\n })\n if (ctx.request.accepts(['html', 'json']) === 'json') return { data }\n ctx.session.flash(\n 'success',\n 'تم إرسال الدعوة بالبريد. يمكن إعادة إرسالها بعد دقيقة؛ عندها يُلغى الرابط السابق.'\n )\n return ctx.response.redirect('/users/invite')\n } catch (error) {\n return this.failure(ctx, error)\n }\n }\n\n async show(ctx: HttpContext) {\n const token = String(ctx.params.token)\n ctx.response.header('Referrer-Policy', 'no-referrer').header('Cache-Control', 'no-store')\n return ctx.inertia.render('auth/invitation', { token, valid: await service().valid(token) })\n }\n\n async accept(ctx: HttpContext) {\n try {\n const { password } = await ctx.request.validateUsing(acceptInvitationValidator)\n const data = await service().accept(String(ctx.params.token), password, (value) =>\n hash.make(value)\n )\n if (ctx.request.accepts(['html', 'json']) === 'json') return { data }\n ctx.session.flash(\n 'success',\n 'تم إنشاء حسابك. سجّل الدخول بكلمة مرورك الجديدة؛ يعيّن المسؤول صلاحيات العمل.'\n )\n return ctx.response.redirect('/login')\n } catch (error) {\n return this.failure(ctx, error)\n }\n }\n\n private failure(ctx: HttpContext, error: unknown) {\n if (ctx.request.accepts(['html', 'json']) === 'json') throw error\n if (error instanceof ValidationError) {\n const messages = Array.isArray(error.messages)\n ? Object.fromEntries(\n error.messages.map((entry: { field: string; message: string }) => [\n entry.field,\n entry.message,\n ])\n )\n : error.messages\n ctx.session.flash('inputErrorsBag', messages)\n } else if (error instanceof KitError) {\n ctx.session.flash('inputErrorsBag', { form: error.message })\n } else throw error\n // Bearer-token pages deliberately omit Referer; redirect to the known GET route explicitly.\n return ctx.response.redirect(ctx.request.url())\n }\n}\n","app/exceptions/handler.ts":"import app from '@adonisjs/core/services/app'\nimport { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'\nimport type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http'\nimport { KitError } from '@adula/kit'\n\nexport default class HttpExceptionHandler extends ExceptionHandler {\n /**\n * In debug mode, the exception handler will display verbose errors\n * with pretty printed stack traces.\n */\n protected debug = !app.inProduction\n\n /**\n * Status pages are used to display a custom HTML pages for certain error\n * codes. You might want to enable them in production only, but feel\n * free to enable them in development as well.\n */\n protected renderStatusPages = app.inProduction\n\n /**\n * Status pages is a collection of error code range and a callback\n * to return the HTML contents to send as a response.\n */\n protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {\n '404': (_, { inertia }) => inertia.render('errors/not_found', {}),\n '500..599': (_, { inertia }) => inertia.render('errors/server_error', {}),\n }\n\n /**\n * The method is used for handling errors and returning\n * response to the client\n */\n async handle(error: unknown, ctx: HttpContext) {\n if (\n (error as { code?: string })?.code === 'E_BAD_CSRF_TOKEN' &&\n (ctx.request.url() === '/mcp' || ctx.request.accepts(['json', 'html']) === 'json')\n )\n return ctx.response.forbidden({\n error: {\n code: 'E_BAD_CSRF_TOKEN',\n message: 'انتهت صلاحية الطلب. حدّث الصفحة وحاول مجدداً.',\n },\n })\n if (error instanceof KitError)\n return ctx.response\n .status(error.status)\n .send({ error: { code: error.code, message: error.message } })\n if ((error as { code?: string })?.code === '23505')\n return ctx.response.conflict({\n error: { code: 'E_DUPLICATE', message: 'هذه القيمة مستخدمة في سجل آخر' },\n })\n if ((error as { code?: string })?.code === '23503')\n return ctx.response.unprocessableEntity({\n error: { code: 'E_RELATION', message: 'تحقق من السجلات المرتبطة' },\n })\n return super.handle(error, ctx)\n }\n\n /**\n * The method is used to report error to the logging service or\n * the a third party error monitoring service.\n *\n * @note You should not attempt to send a response from this method.\n */\n async report(error: unknown, ctx: HttpContext) {\n return super.report(error, ctx)\n }\n}\n","app/jobs/domain_event_job.ts":"import { Job } from '@nemoventures/adonis-jobs'\nimport db from '@adonisjs/lucid/services/db'\nimport { consumeEvent, type DomainEvent } from '@adula/kit'\nimport { listeners } from '#start/listeners'\n\nexport default class DomainEventJob extends Job<DomainEvent, void> {\n static nameOverride = 'adula.domain_event'\n async process() {\n for (const listener of listeners) {\n await consumeEvent(db.connection().getWriteClient(), listener, this.data)\n }\n }\n}\n","app/mails/password_reset_notification.ts":"import { BaseMail } from '@adonisjs/mail'\n\n/** Recovery e-mail carrying the single-use reset link (valid for one hour). */\nexport default class PasswordResetNotification extends BaseMail {\n subject = 'إعادة تعيين كلمة المرور'\n\n constructor(\n private user: { email: string; fullName: string | null },\n public resetUrl: string\n ) {\n super()\n }\n\n prepare() {\n const greeting = this.user.fullName ? `مرحباً ${this.user.fullName}،` : 'مرحباً،'\n this.message\n .to(this.user.email)\n .html(\n `<div dir=\"rtl\" style=\"font-family: system-ui, sans-serif; line-height: 1.8\">\n <p>${greeting}</p>\n <p>وصلنا طلب لإعادة تعيين كلمة مرور حسابك. اضغط الرابط التالي خلال ساعة واحدة لاختيار كلمة مرور جديدة:</p>\n <p><a href=\"${this.resetUrl}\">${this.resetUrl}</a></p>\n <p>إن لم تطلب ذلك فتجاهل هذه الرسالة؛ كلمة مرورك لن تتغير.</p>\n</div>`\n )\n .text(\n `${greeting}\\n\\nوصلنا طلب لإعادة تعيين كلمة مرور حسابك. افتح الرابط التالي خلال ساعة واحدة لاختيار كلمة مرور جديدة:\\n${this.resetUrl}\\n\\nإن لم تطلب ذلك فتجاهل هذه الرسالة؛ كلمة مرورك لن تتغير.`\n )\n }\n}\n","app/mails/user_invitation_notification.ts":"import { BaseMail } from '@adonisjs/mail'\n\nexport default class UserInvitationNotification extends BaseMail {\n subject = 'دعوة لإنشاء حسابك'\n\n constructor(\n private email: string,\n public invitationUrl: string\n ) {\n super()\n }\n\n prepare() {\n this.message\n .to(this.email)\n .text(\n `مرحبًا،\\n\\nدعاك مسؤول النظام لإنشاء حسابك. افتح الرابط التالي خلال 24 ساعة واختر كلمة مرورك:\\n${this.invitationUrl}\\n\\nالرابط للاستخدام مرة واحدة. إن لم تكن تتوقع هذه الدعوة فتجاهلها. لن يُنشأ الحساب قبل قبول الدعوة.`\n )\n }\n}\n","app/mcp/tools/resource_read_tool.ts":"import { resourceTools } from '#services/mcp'\nexport default resourceTools.ResourceReadTool\n","app/mcp/tools/resource_write_tool.ts":"import { resourceTools } from '#services/mcp'\nexport default resourceTools.ResourceWriteTool\n","app/middleware/admin_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport db from '@adonisjs/lucid/services/db'\nimport { NotificationsAdmin, UserInvitations, buildAbility, isBackupStale } from '@adula/kit'\nimport { kit } from '#services/kit'\n\nexport const IMPERSONATOR_KEY = 'impersonator_id'\n\nexport async function isAdministrator(userId: number) {\n const runtime = kit()\n const actor = await runtime.actors.load(userId)\n return buildAbility(actor.rules, runtime.registry.all()).can('manage', 'all')\n}\n\n/** Shell props for every Inertia page: admin navigation, backup bar, bell and impersonation. */\nexport async function sharedAdminProps(ctx: HttpContext) {\n const { auth, session } = ctx as Partial<HttpContext>\n const userId = auth?.user?.id\n const knex = db.connection().getWriteClient()\n const isAdmin = userId ? await isAdministrator(userId) : false\n return {\n isAdmin: ctx.inertia.always(isAdmin),\n canInviteUsers: ctx.inertia.always(\n userId ? await new UserInvitations(knex).canInvite(userId) : false\n ),\n backupWarning: ctx.inertia.always(isAdmin && (await isBackupStale(knex))),\n unreadNotifications: ctx.inertia.always(\n userId ? await new NotificationsAdmin(knex).unreadCount(userId) : 0\n ),\n impersonating: ctx.inertia.always(Boolean(session?.get(IMPERSONATOR_KEY))),\n }\n}\n\nexport default class AdminMiddleware {\n async handle(ctx: HttpContext, next: NextFn) {\n const user = ctx.auth.getUserOrFail()\n if (!(await isAdministrator(user.id))) {\n if (ctx.request.accepts(['html', 'json']) === 'json')\n return ctx.response.forbidden({\n error: { code: 'E_FORBIDDEN', message: 'هذه المنطقة للمديرين فقط' },\n })\n ctx.response.status(403)\n return ctx.response.send(await ctx.inertia.render('admin/forbidden', {}))\n }\n return next()\n }\n}\n","app/middleware/auth_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport type { Authenticators } from '@adonisjs/auth/types'\nimport { ensureSessionActive } from '#services/sessions'\n\n/**\n * Auth middleware is used authenticate HTTP requests and deny\n * access to unauthenticated users. It also ends sessions that were\n * revoked and sessions of disabled users.\n */\nexport default class AuthMiddleware {\n /**\n * The URL to redirect to, when authentication fails\n */\n redirectTo = '/login'\n\n async handle(\n ctx: HttpContext,\n next: NextFn,\n options: {\n guards?: (keyof Authenticators)[]\n } = {}\n ) {\n await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })\n const state = await ensureSessionActive(ctx)\n if (!state.active) {\n await ctx.auth.use('web').logout()\n const message =\n state.reason === 'disabled'\n ? 'هذا الحساب معطّل. تواصل مع مدير النظام.'\n : 'أُنهيت هذه الجلسة. سجّل الدخول مجدداً.'\n if (ctx.request.accepts(['html', 'json']) === 'json')\n return ctx.response.unauthorized({ errors: [{ message }] })\n ctx.session.flash('error', message)\n return ctx.response.redirect().toPath(this.redirectTo)\n }\n return next()\n }\n}\n","app/middleware/container_bindings_middleware.ts":"import { Logger } from '@adonisjs/core/logger'\nimport { HttpContext } from '@adonisjs/core/http'\nimport { type NextFn } from '@adonisjs/core/types/http'\n\n/**\n * The container bindings middleware binds classes to their request\n * specific value using the container resolver.\n *\n * - We bind \"HttpContext\" class to the \"ctx\" object\n * - And bind \"Logger\" class to the \"ctx.logger\" object\n */\nexport default class ContainerBindingsMiddleware {\n handle(ctx: HttpContext, next: NextFn) {\n ctx.containerResolver.bindValue(HttpContext, ctx)\n ctx.containerResolver.bindValue(Logger, ctx.logger)\n\n return next()\n }\n}\n","app/middleware/guest_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport type { Authenticators } from '@adonisjs/auth/types'\n\n/**\n * Guest middleware is used to deny access to routes that should\n * be accessed by unauthenticated users.\n *\n * For example, the login page should not be accessible if the user\n * is already logged-in\n */\nexport default class GuestMiddleware {\n /**\n * The URL to redirect to when user is logged-in\n */\n redirectTo = '/'\n\n async handle(\n ctx: HttpContext,\n next: NextFn,\n options: { guards?: (keyof Authenticators)[] } = {}\n ) {\n for (let guard of options.guards || [ctx.auth.defaultGuard]) {\n if (await ctx.auth.use(guard).check()) {\n ctx.session.reflash()\n return ctx.response.redirect(this.redirectTo, true)\n }\n }\n\n return next()\n }\n}\n","app/middleware/inertia_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport UserTransformer from '#transformers/user_transformer'\nimport BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware'\nimport { kit } from '#services/kit'\nimport { sharedAdminProps } from '#middleware/admin_middleware'\nimport { uiPreferences } from '@adula/kit'\nimport db from '@adonisjs/lucid/services/db'\n\nexport default class InertiaMiddleware extends BaseInertiaMiddleware {\n async share(ctx: HttpContext) {\n /**\n * The share method is called everytime an Inertia page is rendered. In\n * certain cases, a page may get rendered before the session middleware\n * or the auth middleware are executed. For example: During a 404 request.\n *\n * In that case, we must always assume that HttpContext is not fully hydrated\n * with all the properties\n */\n const { auth } = ctx as Partial<HttpContext>\n\n /**\n * Data shared with all Inertia pages. Make sure you are using\n * transformers for rich data-types like Models.\n */\n return {\n errors: ctx.inertia.always(this.getValidationErrors(ctx)),\n uiPreferences: ctx.inertia.always(await uiPreferences(db.connection().getWriteClient())),\n user: ctx.inertia.always(auth?.user ? UserTransformer.transform(auth.user) : undefined),\n navigation: ctx.inertia.always(\n auth?.user ? kit().resources.navigation(await kit().actors.load(auth.user.id)) : []\n ),\n ...(await sharedAdminProps(ctx)),\n }\n }\n\n /**\n * The flash bag is sent to every Inertia page as a top-level \"flash\" field\n * (a sibling of \"props\") and is read on the client using \"usePage().flash\".\n *\n * Just like the share method, the flash method may run before the session\n * middleware, so HttpContext must be treated as partially hydrated.\n */\n flash(ctx: HttpContext) {\n const { session } = ctx as Partial<HttpContext>\n\n /**\n * Fetching the first error from the flash messages\n */\n return {\n error: session?.flashMessages.get('error') as string | undefined,\n success: session?.flashMessages.get('success') as string | undefined,\n }\n }\n\n async handle(ctx: HttpContext, next: NextFn) {\n await this.init(ctx)\n\n const output = await next()\n this.dispose(ctx)\n\n return output\n }\n}\n\ndeclare module '@adonisjs/inertia/types' {\n type MiddlewareSharedProps = InferSharedProps<InertiaMiddleware>\n export interface SharedProps extends MiddlewareSharedProps {}\n}\n","app/middleware/mcp_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport { isModernProtocolRequest } from '@jrmc/adonis-mcp/protocols/version'\nimport { randomUUID } from 'node:crypto'\n\n// Adapted from @jrmc/adonis-mcp 2.0.0's official middleware stub (MIT).\n// Session authentication and Shield CSRF remain enabled on this route.\nexport default class McpMiddleware {\n async handle(ctx: HttpContext, next: NextFn) {\n const body = ctx.request.body()\n if (ctx.request.header('Content-Type')?.split(';', 1)[0] !== 'application/json')\n return ctx.response.badRequest('Content-Type header must be application/json')\n if (\n isModernProtocolRequest(\n ctx.request.header('MCP-Protocol-Version'),\n body.params?._meta?.['io.modelcontextprotocol/protocolVersion']\n )\n )\n return next()\n if (body.method === 'initialize') ctx.response.safeHeader('MCP-Session-Id', randomUUID())\n else {\n const sessionId = ctx.request.header('MCP-Session-Id')\n if (!sessionId) return ctx.response.badRequest('MCP-Session-Id header is required')\n ctx.response.safeHeader('MCP-Session-Id', sessionId)\n }\n return next()\n }\n}\n","app/middleware/silent_auth_middleware.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport { ensureSessionActive } from '#services/sessions'\n\n/**\n * Silent auth middleware can be used as a global middleware to silent check\n * if the user is logged-in or not.\n *\n * The request continues as usual, even when the user is not logged-in. A\n * revoked session or a disabled user is silently signed out.\n */\nexport default class SilentAuthMiddleware {\n async handle(ctx: HttpContext, next: NextFn) {\n await ctx.auth.check()\n if (ctx.auth.isAuthenticated) {\n const state = await ensureSessionActive(ctx)\n if (!state.active) await ctx.auth.use('web').logout()\n }\n\n return next()\n }\n}\n","app/models/user.ts":"import { UserSchema } from '#database/schema'\nimport hash from '@adonisjs/core/services/hash'\nimport { compose } from '@adonisjs/core/helpers'\nimport { withAuthFinder } from '@adonisjs/auth/mixins/lucid'\n\nexport default class User extends compose(UserSchema, withAuthFinder(hash)) {\n get initials() {\n const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')\n if (first && last) {\n return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()\n }\n return `${first.slice(0, 2)}`.toUpperCase()\n }\n}\n","app/services/auth_activity.ts":"import db from '@adonisjs/lucid/services/db'\nimport type { HttpContext } from '@adonisjs/core/http'\n\n/** Authentication events recorded in the kit \"activities\" table (resource \"users\"). */\nexport type AuthAction =\n | 'login'\n | 'login_failed'\n | 'logout'\n | 'password_reset_requested'\n | 'password_reset_delivery_failed'\n | 'password_reset'\n | 'session_revoked'\n | 'oauth_login'\n | 'profile_updated'\n | 'password_changed'\n\nexport type AuthActivityEntry = {\n /** The user the event is about; also the record id of the activity row. */\n userId: number\n /** Defaults to the user (self-service). Administrators pass their own id. */\n actorId?: number\n action: AuthAction\n changes?: Record<string, unknown>\n}\n\ntype Client = ReturnType<ReturnType<typeof db.connection>['getWriteClient']>\n\n/** Request facts stored with every authentication activity. */\nexport function requestContext(ctx: Pick<HttpContext, 'request'>) {\n return {\n ip: ctx.request.ip(),\n userAgent: ctx.request.header('user-agent')?.slice(0, 512) ?? null,\n }\n}\n\n/**\n * Failed logins for unknown e-mails cannot be stored here (actor_id references\n * users); the limiter alone counts those.\n */\nexport async function logAuthActivity(\n entry: AuthActivityEntry,\n client: Client = db.connection().getWriteClient()\n) {\n await client('activities').insert({\n resource: 'users',\n record_id: entry.userId,\n actor_id: entry.actorId ?? entry.userId,\n action: entry.action,\n changes: JSON.stringify(entry.changes ?? {}),\n })\n}\n","app/services/backup_publish.ts":"import { createHash } from 'node:crypto'\nimport { join } from 'node:path'\nimport { fileHash, snapshotFiles } from '#services/backup_snapshot'\n\nexport interface SnapshotTransport {\n exists(): Promise<boolean>\n put(name: string, file?: string): Promise<void>\n read(name: string): Promise<AsyncIterable<Uint8Array>>\n}\n\n/** A remote completion marker is a commit: no success marker on partial/corrupt transfer. */\nexport async function publishSnapshot(directory: string, transport: SnapshotTransport) {\n if (await transport.exists()) throw new Error('Refusing to overwrite a complete offsite snapshot')\n for (const name of [...snapshotFiles, 'SHA256SUMS']) {\n const path = join(directory, name)\n await transport.put(name, path)\n const hash = createHash('sha256')\n for await (const chunk of await transport.read(name)) hash.update(chunk)\n if (hash.digest('hex') !== (await fileHash(path)))\n throw new Error(`Offsite checksum mismatch: ${name}`)\n }\n await transport.put('COMPLETE')\n if (!(await transport.exists())) throw new Error('Offsite completion marker is missing')\n}\n","app/services/backup_snapshot.ts":"import { createHash, randomUUID } from 'node:crypto'\nimport { createReadStream, createWriteStream } from 'node:fs'\nimport { mkdir, mkdtemp, readFile, rm, stat, writeFile, lstat } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\nimport { pipeline } from 'node:stream/promises'\nimport { execFile } from 'node:child_process'\nimport { promisify } from 'node:util'\nimport db from '@adonisjs/lucid/services/db'\nimport drive from '@adonisjs/drive/services/main'\nimport { isRelativeDiskPath } from '@adula/kit'\nimport env from '#start/env'\n\nconst run = promisify(execFile)\nexport const snapshotFiles = ['database.dump', 'uploads.tar.gz', 'attachments.json']\nexport type ArchivedAttachment = {\n id: number\n disk: string\n path: string\n size: number\n sha256: string\n}\nexport const pgEnvironment = () => ({\n ...process.env,\n PGHOST: env.get('DB_HOST'),\n PGPORT: String(env.get('DB_PORT')),\n PGUSER: env.get('DB_USER'),\n PGPASSWORD: env.get('DB_PASSWORD'),\n})\n\nexport async function fileHash(path: string) {\n const hash = createHash('sha256')\n for await (const chunk of createReadStream(path)) hash.update(chunk)\n return hash.digest('hex')\n}\n\nexport function attachmentDisk(name: string) {\n return drive.use(name as 'local' | 's3')\n}\n\n/** The dump and attachment inventory share one PostgreSQL MVCC snapshot. */\nexport async function createSnapshot(directory: string) {\n await mkdir(directory) // Never reuse a partially or fully published snapshot.\n const objects = await mkdtemp(join(tmpdir(), 'adula-backup-'))\n try {\n const entries: ArchivedAttachment[] = []\n await db\n .connection()\n .getWriteClient()\n .transaction(async (trx) => {\n await trx.raw('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY')\n const result = await trx.raw('SELECT pg_export_snapshot() AS snapshot')\n await run(\n 'pg_dump',\n [\n '--format=custom',\n `--snapshot=${result.rows[0].snapshot}`,\n `--file=${join(directory, 'database.dump')}`,\n env.get('DB_DATABASE'),\n ],\n { env: pgEnvironment(), windowsHide: true, timeout: 3600000 }\n )\n // Include released/soft-deleted files too: their rows remain in the dump.\n const rows = await trx('attachments').orderBy('id')\n for (const row of rows) {\n if (!isRelativeDiskPath(row.path)) throw new Error(`Unsafe attachment path: ${row.path}`)\n const file = join(objects, `${row.id}.bin`)\n await pipeline(\n await attachmentDisk(row.disk).getStream(row.path),\n createWriteStream(file)\n )\n const info = await stat(file)\n const size = info.size\n if (size !== Number(row.size)) throw new Error(`Attachment ${row.id} size mismatch`)\n entries.push({\n id: Number(row.id),\n disk: row.disk,\n path: row.path,\n size,\n sha256: await fileHash(file),\n })\n }\n })\n await run('tar', ['-czf', 'uploads.tar.gz', '-C', objects.replaceAll('\\\\', '/'), '.'], {\n cwd: directory,\n windowsHide: true,\n timeout: 3600000,\n })\n await writeFile(\n join(directory, 'attachments.json'),\n JSON.stringify({ version: 1, files: entries }, null, 2) + '\\n'\n )\n const sums = await Promise.all(\n snapshotFiles.map(async (name) => `${await fileHash(join(directory, name))} ${name}`)\n )\n await writeFile(join(directory, 'SHA256SUMS'), sums.join('\\n') + '\\n')\n } finally {\n await rm(objects, { recursive: true, force: true })\n }\n}\n\nexport async function verifySnapshot(directory: string) {\n await stat(join(directory, 'COMPLETE'))\n const text = await readFile(join(directory, 'SHA256SUMS'), 'utf8')\n const names = new Set<string>()\n for (const line of text.trim().split(/\\r?\\n/)) {\n const match = /^([a-f0-9]{64}) {2}(database\\.dump|uploads\\.tar\\.gz|attachments\\.json)$/.exec(\n line\n )\n if (!match || names.has(match[2])) throw new Error('Malformed SHA256SUMS')\n names.add(match[2])\n if ((await fileHash(join(directory, match[2]))) !== match[1])\n throw new Error(`Checksum mismatch for ${match[2]}`)\n }\n if (!names.has('database.dump') || !names.has('uploads.tar.gz'))\n throw new Error('Incomplete SHA256SUMS')\n const hasManifest = await stat(join(directory, 'attachments.json')).then(\n () => true,\n (error) => {\n if (error.code !== 'ENOENT') throw error\n return false\n }\n )\n if (hasManifest !== names.has('attachments.json'))\n throw new Error('Manifest checksum is required')\n if (!hasManifest) return null // Legacy local-only snapshots remain readable.\n const manifest = JSON.parse(await readFile(join(directory, 'attachments.json'), 'utf8'))\n if (manifest.version !== 1 || !Array.isArray(manifest.files))\n throw new Error('Unsupported attachment manifest')\n const ids = new Set<number>()\n for (const file of manifest.files) {\n if (\n !Number.isSafeInteger(file.id) ||\n file.id < 1 ||\n ids.has(file.id) ||\n typeof file.disk !== 'string' ||\n typeof file.path !== 'string' ||\n !isRelativeDiskPath(file.path) ||\n !Number.isSafeInteger(file.size) ||\n file.size < 0 ||\n !/^[a-f0-9]{64}$/.test(file.sha256)\n )\n throw new Error('Invalid attachment manifest entry')\n attachmentDisk(file.disk)\n ids.add(file.id)\n }\n return manifest.files as ArchivedAttachment[]\n}\n\nexport async function extractSnapshot(directory: string, target: string) {\n const archive = join(directory, 'uploads.tar.gz')\n const flags = process.platform === 'win32' ? ['--force-local'] : []\n const options = { windowsHide: true, timeout: 3600000, maxBuffer: 64 * 1024 * 1024 }\n const listing = await run('tar', [...flags, '-tzf', archive], options)\n for (const entry of listing.stdout.trim().split(/\\r?\\n/)) {\n const path = entry.replace(/^\\.\\//, '').replace(/\\/$/, '')\n if (path && path !== '.' && !isRelativeDiskPath(path)) throw new Error('Unsafe archive path')\n }\n // Reject links/devices before extraction; generated snapshots contain regular files only.\n const verbose = await run('tar', [...flags, '-tvzf', archive], options)\n if (verbose.stdout.split(/\\r?\\n/).some((line) => line && !['-', 'd'].includes(line[0])))\n throw new Error('Archive contains a link or special file')\n await run('tar', [...flags, '-xzf', archive], { ...options, cwd: target })\n}\n\nexport async function verifyAttachments(\n files: ArchivedAttachment[],\n extracted: string,\n rows: Record<string, any>[]\n) {\n if (files.length !== rows.length)\n throw new Error('Attachment inventory differs from restored database')\n const inventory = new Map(files.map((file) => [file.id, file]))\n for (const row of rows) {\n const file = inventory.get(Number(row.id))\n if (!file || file.disk !== row.disk || file.path !== row.path || file.size !== Number(row.size))\n throw new Error(`Attachment ${row.id} differs from restored database`)\n const path = join(extracted, `${file.id}.bin`)\n const info = await lstat(path)\n if (!info.isFile() || info.size !== file.size || (await fileHash(path)) !== file.sha256)\n throw new Error(`Attachment ${file.id} checksum mismatch`)\n }\n}\n\n/** Drills write only isolated keys; actual recovery explicitly writes original keys. */\nexport async function restoreAttachments(\n files: ArchivedAttachment[],\n extracted: string,\n drill: boolean\n) {\n const prefix = `.adula-restore-tests/${randomUUID()}`\n for (const file of files) {\n const disk = attachmentDisk(file.disk)\n const key = drill ? `${prefix}/${file.id}.bin` : file.path\n try {\n await disk.putStream(key, createReadStream(join(extracted, `${file.id}.bin`)), {\n contentLength: file.size,\n })\n const hash = createHash('sha256')\n let size = 0\n for await (const chunk of await disk.getStream(key)) {\n hash.update(chunk)\n size += Buffer.byteLength(chunk)\n }\n if (size !== file.size || hash.digest('hex') !== file.sha256)\n throw new Error(`Restored attachment ${file.id} checksum mismatch`)\n } finally {\n if (drill) await disk.delete(key)\n }\n }\n}\n","app/services/events.ts":"import db from '@adonisjs/lucid/services/db'\nimport { AdonisJobs, publishOutbox, type DomainEvent } from '@adula/kit'\nimport DomainEventJob from '../jobs/domain_event_job.js'\n\nexport const jobs = new AdonisJobs({\n 'adula.domain_event': async (data, id) => {\n const event: DomainEvent = {\n id,\n event: String(data.event),\n payload: data.payload as DomainEvent['payload'],\n }\n return await DomainEventJob.dispatch(event).with('jobId', id)\n },\n})\nexport function publishEvents() {\n return publishOutbox(db.connection().getWriteClient(), jobs)\n}\n","app/services/initial_setup.ts":"import { createHmac, randomUUID } from 'node:crypto'\nimport { readFile } from 'node:fs/promises'\nimport { Readable } from 'node:stream'\nimport app from '@adonisjs/core/services/app'\nimport db from '@adonisjs/lucid/services/db'\nimport redis from '@adonisjs/redis/services/main'\nimport drive from '@adonisjs/drive/services/main'\nimport { InitialSetup, Settings, runtimeHealth, type SetupCheck } from '@adula/kit'\nimport env from '#start/env'\nimport { mailTest, mailFingerprint, publicMailTest } from '#services/mail_delivery_test'\n\nconst database = () => db.connection().getWriteClient()\nexport const setup = () => new InitialSetup(database())\nexport const backupFingerprint = () =>\n fingerprint([\n env.get('BACKUP_S3_ENDPOINT'),\n env.get('BACKUP_S3_BUCKET'),\n env.get('BACKUP_S3_PREFIX') ?? 'adula',\n env.get('BACKUP_S3_REGION'),\n env.get('BACKUP_S3_ACCESS_KEY_ID'),\n env.get('BACKUP_S3_SECRET_ACCESS_KEY'),\n ])\nexport const fingerprint = (values: unknown[]) =>\n createHmac('sha256', env.get('APP_KEY').release()).update(JSON.stringify(values)).digest('hex')\nexport const storageFingerprint = () =>\n fingerprint([\n env.get('DRIVE_DISK'),\n env.get('AWS_ENDPOINT'),\n env.get('S3_BUCKET'),\n env.get('AWS_REGION'),\n env.get('AWS_ACCESS_KEY_ID'),\n env.get('AWS_SECRET_ACCESS_KEY'),\n ])\nexport const infrastructureFingerprint = () =>\n fingerprint([\n env.get('DB_HOST'),\n env.get('DB_PORT'),\n env.get('DB_DATABASE'),\n env.get('DB_USER'),\n env.get('DB_PASSWORD'),\n env.get('REDIS_HOST'),\n env.get('REDIS_PORT'),\n env.get('REDIS_PASSWORD'),\n ])\nexport const oauthFingerprint = (provider: 'github' | 'google') =>\n fingerprint(\n provider === 'github'\n ? [env.get('GITHUB_CLIENT_ID'), env.get('GITHUB_CLIENT_SECRET'), env.get('APP_URL')]\n : [env.get('GOOGLE_CLIENT_ID'), env.get('GOOGLE_CLIENT_SECRET'), env.get('APP_URL')]\n )\n\nexport async function identity() {\n try {\n const brand = JSON.parse(await readFile(app.makePath('company-identity.json'), 'utf8')) as {\n company: string\n logo?: string\n }\n return { company: String(brand.company), logo: brand.logo ?? null }\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n const root = await database()('org_units').whereNull('parent_id').orderBy('id').first('name')\n return { company: root?.name ?? 'التطبيق المرجعي', logo: null }\n }\n}\n\nexport async function probeStorage() {\n const disk = drive.use()\n const path = `.adula-setup/${randomUUID()}.txt`\n const payload = Buffer.from(`adula-storage-check:${randomUUID()}`)\n try {\n await disk.putStream(path, Readable.from(payload), { contentLength: payload.length })\n const chunks: Buffer[] = []\n for await (const chunk of await disk.getStream(path)) chunks.push(Buffer.from(chunk))\n if (!Buffer.concat(chunks).equals(payload)) throw new Error('Storage round trip mismatch')\n } finally {\n await disk.delete(path)\n }\n}\nexport async function identityFingerprint() {\n const sources = await Promise.all(\n ['docs/design-identity.md', 'inertia/css/brand.css', 'inertia/brand.ts'].map(async (path) => {\n try {\n return await readFile(app.makePath(path), 'utf8')\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''\n throw error\n }\n })\n )\n return fingerprint([await identity(), ...sources])\n}\nexport async function probeInfrastructure() {\n await database().raw('SELECT 1')\n if ((await redis.ping()) !== 'PONG') throw new Error('Redis unavailable')\n}\n\nexport async function setupSnapshot(user: { id: number; email: string }) {\n const settings = new Settings(database())\n const brand = await identity()\n const approved = await settings.get<{ fingerprint: string }>('setup.identity')\n const health = await runtimeHealth(database())\n const currentCheck = async (name: string, key: string) => {\n const state = await settings.get<SetupCheck>(`setup.check.${name}`)\n if (!state || state.fingerprint !== key) return null\n return {\n status: state.status,\n checkedAt: state.checkedAt,\n fresh: Date.now() - Date.parse(state.checkedAt) < 86_400_000,\n }\n }\n const notification = await settings.get<{ id: number; at: string }>(\n 'setup.notification',\n 'user',\n String(user.id)\n )\n const receipt = notification\n ? await database()('notifications')\n .where({ id: notification.id, user_id: user.id })\n .first('read_at')\n : null\n const mail = await mailTest().current(user.id, user.email, mailFingerprint())\n const oauth = await Promise.all(\n (['github', 'google'] as const).map(async (provider) => {\n const configured =\n provider === 'github'\n ? Boolean(env.get('GITHUB_CLIENT_ID') && env.get('GITHUB_CLIENT_SECRET'))\n : Boolean(env.get('GOOGLE_CLIENT_ID') && env.get('GOOGLE_CLIENT_SECRET'))\n const proof = await settings.get<{ fingerprint: string; at: string }>(\n `setup.oauth.${provider}`\n )\n return {\n provider,\n configured,\n verifiedAt:\n configured && proof?.fingerprint === oauthFingerprint(provider) ? proof.at : null,\n }\n })\n )\n const storage = await currentCheck('storage', storageFingerprint())\n const infrastructure = await currentCheck('infrastructure', infrastructureFingerprint())\n const backupConfigured = [\n 'BACKUP_S3_ENDPOINT',\n 'BACKUP_S3_BUCKET',\n 'BACKUP_S3_REGION',\n 'BACKUP_S3_ACCESS_KEY_ID',\n 'BACKUP_S3_SECRET_ACCESS_KEY',\n ].every((key) => Boolean(env.get(key as 'BACKUP_S3_BUCKET')))\n const backupHealth = await settings.get<{ healthy: boolean; checkedAt: string }>('backup.health')\n const backupProof = await settings.get<{ fingerprint: string }>('setup.backup_check')\n const restore = await settings.get<{\n status?: 'passed' | 'failed'\n finishedAt?: string\n error?: string\n attachment?: { fileVerified?: boolean }\n }>('backup.lastRestoreTestReport')\n return {\n brand,\n identityConfirmed: approved?.fingerprint === (await identityFingerprint()),\n mailTest: publicMailTest(mail),\n mailRecipient: user.email,\n notification: notification ? { ...notification, read: Boolean(receipt?.read_at) } : null,\n storage,\n storageDisk: env.get('DRIVE_DISK'),\n infrastructure,\n health,\n oauth,\n backup: {\n configured: backupConfigured,\n inspection: backupProof?.fingerprint === backupFingerprint() ? (backupHealth ?? null) : null,\n restore: restore\n ? {\n status: restore.status,\n finishedAt: restore.finishedAt,\n fileVerified: Boolean(restore.attachment?.fileVerified),\n }\n : null,\n },\n environment: app.inProduction ? 'production' : 'development',\n }\n}\n","app/services/kit.ts":"import db from '@adonisjs/lucid/services/db'\nimport { ActorStore, ResourceService, SavedViews } from '@adula/kit'\nimport { registry } from '#start/modules'\nimport cache from '@adonisjs/cache/services/main'\nimport type { Actor } from '@adula/kit'\n\nexport function kit() {\n const knex = db.connection().getWriteClient()\n return {\n registry,\n resources: new ResourceService(knex, registry),\n savedViews: new SavedViews(knex, registry),\n actors: new ActorStore(knex, registry, {\n get: async (key) => (await cache.get<Actor>({ key })) ?? undefined,\n set: async (key, value) => {\n await cache.set({ key, value, ttl: '5m' })\n },\n }),\n }\n}\n","app/services/mail_delivery_test.ts":"import { createHmac } from 'node:crypto'\nimport mail from '@adonisjs/mail/services/main'\nimport { MailDeliveryTest, type MailTestState } from '@adula/kit'\nimport db from '@adonisjs/lucid/services/db'\nimport env from '#start/env'\n\nexport const mailTest = () => new MailDeliveryTest(db.connection().getWriteClient())\nexport function publicMailTest(\n state: MailTestState | null\n): Omit<MailTestState, 'fingerprint'> | null {\n if (!state) return null\n return {\n id: state.id,\n recipient: state.recipient,\n status: state.status,\n requestedAt: state.requestedAt,\n answeredAt: state.answeredAt,\n }\n}\n// Configuration changes invalidate old attestations; secrets never reach page props.\nexport function mailFingerprint() {\n return createHmac('sha256', env.get('APP_KEY').release())\n .update(\n JSON.stringify([\n env.get('SMTP_HOST'),\n env.get('SMTP_PORT'),\n env.get('SMTP_SECURE'),\n env.get('SMTP_REQUIRE_TLS'),\n env.get('SMTP_USERNAME'),\n env.get('SMTP_PASSWORD'),\n env.get('MAIL_FROM_ADDRESS'),\n env.get('MAIL_FROM_NAME'),\n env.get('MAIL_MAILER'),\n ])\n )\n .digest('hex')\n}\nexport async function sendMailTest(recipient: string, id: string) {\n await mail.send((message) => {\n message\n .to(recipient)\n .subject('تجربة البريد — تأكيد الاستلام')\n .text(\n `هذه رسالة تجريبية طلبتها من إعدادات النظام.\\nمرجع التجربة: ${id}\\nارجع إلى إعدادات البريد واختر «وصلت الرسالة» لتأكيد استلام هذه التجربة.\\nقبول خادم البريد للإرسال لا يعني تأكيد وصولها.`\n )\n })\n}\n","app/services/mcp.ts":"import type { HttpContext } from '@adonisjs/core/http'\nimport { createResourceTools } from '@adula/kit/mcp'\nimport { KitError } from '@adula/kit'\nimport { kit } from '#services/kit'\n\ndeclare module '@jrmc/adonis-mcp/types/context' {\n interface McpContext {\n auth?: HttpContext['auth']\n }\n}\n\nexport const resourceTools = createResourceTools(async (context) => {\n const user = context.auth?.user\n if (!user) throw new KitError(401, 'E_UNAUTHORIZED', 'سجل الدخول أولاً')\n const runtime = kit()\n return { ...runtime, actor: await runtime.actors.load(user.id) }\n})\n","app/services/sessions.ts":"import db from '@adonisjs/lucid/services/db'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { logAuthActivity, requestContext } from '#services/auth_activity'\n\n/** A signed-in browser session as shown to users and administrators. */\nexport type UserSession = {\n id: string\n userId: number\n ip: string | null\n userAgent: string | null\n createdAt: string\n lastSeenAt: string\n revokedAt: string | null\n}\nexport type ActiveSession = UserSession & { email: string; fullName: string | null }\n\n/** Presence is refreshed at most once per window to keep reads cheap. */\nconst PRESENCE_WINDOW_MS = 5 * 60 * 1000\n\nconst knex = () => db.connection().getWriteClient()\nconst iso = (value: unknown) => (value instanceof Date ? value.toISOString() : String(value))\nfunction toSession(row: Record<string, unknown>): UserSession {\n return {\n id: String(row.id),\n userId: Number(row.user_id),\n ip: (row.ip as string | null) ?? null,\n userAgent: (row.user_agent as string | null) ?? null,\n createdAt: iso(row.created_at),\n lastSeenAt: iso(row.last_seen_at),\n revokedAt: row.revoked_at ? iso(row.revoked_at) : null,\n }\n}\n\n/** Records the current session for a user right after login, signup or OAuth login. */\nexport async function recordSession(ctx: HttpContext, userId: number) {\n const { ip, userAgent } = requestContext(ctx)\n await knex()('user_sessions')\n .insert({ id: ctx.session.sessionId, user_id: userId, ip, user_agent: userAgent })\n .onConflict('id')\n .merge({ user_id: userId, ip, user_agent: userAgent, last_seen_at: knex().fn.now() })\n}\n\n/** A voluntary logout closes the row without a \"session_revoked\" activity. */\nexport async function endSession(sessionId: string) {\n await knex()('user_sessions')\n .where({ id: sessionId })\n .whereNull('revoked_at')\n .update({ revoked_at: knex().fn.now() })\n}\n\nexport type SessionState = { active: true } | { active: false; reason: 'disabled' | 'revoked' }\n\n/** The silent and strict auth middleware both check; one query per request is enough. */\nconst checked = new WeakMap<HttpContext, Promise<SessionState>>()\n\n/**\n * Verifies that the authenticated request still owns a live session: the user\n * is not disabled and the session row was not revoked. Sessions created outside\n * the login flow (for example by the test client) are recorded lazily, and the\n * presence timestamp is refreshed at most every five minutes.\n */\nexport function ensureSessionActive(ctx: HttpContext): Promise<SessionState> {\n let pending = checked.get(ctx)\n if (!pending) {\n pending = checkSession(ctx)\n checked.set(ctx, pending)\n }\n return pending\n}\n\nasync function checkSession(ctx: HttpContext): Promise<SessionState> {\n const user = ctx.auth.user\n if (!user) return { active: true }\n if (user.disabledAt) return { active: false, reason: 'disabled' }\n const sessionId = ctx.session.sessionId\n const row = await knex()('user_sessions').where({ id: sessionId }).first()\n if (!row) {\n const { ip, userAgent } = requestContext(ctx)\n await knex()('user_sessions')\n .insert({ id: sessionId, user_id: user.id, ip, user_agent: userAgent })\n .onConflict('id')\n .ignore()\n return { active: true }\n }\n if (row.revoked_at) return { active: false, reason: 'revoked' }\n const stale = Date.now() - new Date(row.last_seen_at).getTime() > PRESENCE_WINDOW_MS\n if (stale || row.user_id !== user.id) {\n const { ip, userAgent } = requestContext(ctx)\n await knex()('user_sessions')\n .where({ id: sessionId })\n .update({ user_id: user.id, ip, user_agent: userAgent, last_seen_at: knex().fn.now() })\n }\n return { active: true }\n}\n\n/** Live sessions of one user, most recently seen first. */\nexport async function listUserSessions(userId: number): Promise<UserSession[]> {\n const rows = await knex()('user_sessions')\n .where({ user_id: userId })\n .whereNull('revoked_at')\n .orderBy('last_seen_at', 'desc')\n return rows.map(toSession)\n}\n\n/** Every live session across users, for the administrative screen. */\nexport async function listActiveSessions(): Promise<ActiveSession[]> {\n const rows = await knex()('user_sessions')\n .join('users', 'users.id', 'user_sessions.user_id')\n .whereNull('user_sessions.revoked_at')\n .orderBy('user_sessions.last_seen_at', 'desc')\n .select('user_sessions.*', 'users.email', 'users.full_name')\n return rows.map((row) => ({\n ...toSession(row),\n email: String(row.email),\n fullName: (row.full_name as string | null) ?? null,\n }))\n}\n\n/**\n * Revokes one session: marks the row, deletes the session-store row so the\n * guard drops it on the next request, and records the activity for the owner.\n * Returns null when the session is unknown or already revoked.\n */\nexport async function revokeSession(sessionId: string, actorId: number) {\n return await knex().transaction(async (trx) => {\n const [row] = await trx('user_sessions')\n .where({ id: sessionId })\n .whereNull('revoked_at')\n .update({ revoked_at: trx.fn.now() })\n .returning('*')\n if (!row) return null\n await trx('sessions').where({ id: sessionId }).delete()\n await logAuthActivity(\n {\n userId: row.user_id,\n actorId,\n action: 'session_revoked',\n changes: { sessionIds: [sessionId], ip: row.ip, userAgent: row.user_agent },\n },\n trx\n )\n return toSession(row)\n })\n}\n\n/** Revokes every live session of a user, optionally keeping the current one. */\nexport async function revokeUserSessions(\n userId: number,\n actorId: number,\n options: { except?: string } = {}\n) {\n return await knex().transaction(async (trx) => {\n const query = trx('user_sessions').where({ user_id: userId }).whereNull('revoked_at')\n if (options.except) query.whereNot({ id: options.except })\n const rows = await query.update({ revoked_at: trx.fn.now() }).returning('*')\n if (!rows.length) return 0\n const ids = rows.map((row) => String(row.id))\n await trx('sessions').whereIn('id', ids).delete()\n await logAuthActivity(\n { userId, actorId, action: 'session_revoked', changes: { sessionIds: ids } },\n trx\n )\n return rows.length\n })\n}\n","app/services/social_accounts.ts":"import env from '#start/env'\nimport User from '#models/user'\nimport db from '@adonisjs/lucid/services/db'\nimport { randomBytes } from 'node:crypto'\nimport type { SocialProviders } from '@adonisjs/ally/types'\n\nexport type SocialProvider = keyof SocialProviders\nexport type SocialProviderOption = { name: SocialProvider; label: string }\n\n/**\n * A provider is offered only when both of its credentials exist. The list is\n * computed once at boot and shared with the login page as \"socialProviders\".\n */\nconst catalogue: (SocialProviderOption & { id: string | undefined; secret: string | undefined })[] =\n [\n {\n name: 'github',\n label: 'GitHub',\n id: env.get('GITHUB_CLIENT_ID'),\n secret: env.get('GITHUB_CLIENT_SECRET'),\n },\n {\n name: 'google',\n label: 'Google',\n id: env.get('GOOGLE_CLIENT_ID'),\n secret: env.get('GOOGLE_CLIENT_SECRET'),\n },\n ]\nconst configured: SocialProviderOption[] = catalogue\n .filter((provider) => provider.id && provider.secret)\n .map(({ name, label }) => ({ name, label }))\n\nexport function socialProviders(): SocialProviderOption[] {\n return configured\n}\n\nexport function isSocialProvider(value: unknown): value is SocialProvider {\n return configured.some((provider) => provider.name === value)\n}\n\nexport type SocialProfile = {\n provider: SocialProvider\n providerId: string\n /** Verified by the provider; callers must refuse unverified addresses first. */\n email: string\n name: string | null\n}\n\n/**\n * Resolves the local user for an OAuth identity: an existing link wins, then a\n * user with the same e-mail is linked, otherwise a user is created with an\n * unguessable password. Runs in one transaction against PostgreSQL.\n */\nexport async function linkOrCreateSocialUser(profile: SocialProfile) {\n return await db.transaction(async (trx) => {\n const link = await trx\n .from('social_accounts')\n .where({ provider: profile.provider, provider_id: profile.providerId })\n .first()\n if (link) {\n const linked = await User.query({ client: trx }).where('id', link.user_id).firstOrFail()\n return { user: linked, created: false, linked: false }\n }\n let user = await User.query({ client: trx })\n .whereRaw('lower(email) = lower(?)', [profile.email])\n .first()\n let created = false\n if (!user) {\n user = await User.create(\n {\n email: profile.email,\n fullName: profile.name,\n password: randomBytes(32).toString('base64url'),\n },\n { client: trx }\n )\n created = true\n }\n await trx\n .table('social_accounts')\n .insert({ provider: profile.provider, provider_id: profile.providerId, user_id: user.id })\n return { user, created, linked: !created }\n })\n}\n","app/transformers/user_transformer.ts":"import type User from '#models/user'\nimport { BaseTransformer } from '@adonisjs/core/transformers'\n\nexport default class UserTransformer extends BaseTransformer<User> {\n toObject() {\n return this.pick(this.resource, [\n 'id',\n 'fullName',\n 'email',\n 'createdAt',\n 'updatedAt',\n 'initials',\n ])\n }\n}\n","app/validators/user.ts":"import vine, { SimpleMessagesProvider } from '@vinejs/vine'\n\n/**\n * Shared rules for email and password.\n */\nconst email = () => vine.string().email().maxLength(254)\nconst password = () => vine.string().minLength(8).maxLength(32)\nexport const invitationValidator = vine.create({\n fullName: vine.string().trim().minLength(1).maxLength(120),\n email: vine.string().trim().email().maxLength(254),\n})\nconst invitationMessages = new SimpleMessagesProvider({\n required: 'هذا الحقل مطلوب',\n string: 'أدخل قيمة نصية صحيحة',\n email: 'أدخل بريدًا إلكترونيًا صحيحًا',\n minLength: 'عدد الأحرف أقل من الحد المطلوب ({{ min }})',\n maxLength: 'عدد الأحرف يتجاوز الحد المسموح ({{ max }})',\n confirmed: 'يجب أن تتطابق كلمتا المرور',\n})\ninvitationValidator.messagesProvider = invitationMessages\n\nexport const acceptInvitationValidator = vine.create({\n password: password().confirmed({ confirmationField: 'passwordConfirmation' }),\n passwordConfirmation: vine.string(),\n})\nacceptInvitationValidator.messagesProvider = invitationMessages\n\n/**\n * Validator to use when performing self-signup.\n *\n * The \"passwordConfirmation\" field is declared explicitly, so that it is part\n * of the request body type shared with the frontend. Otherwise the signup form\n * has no way to know about the errors reported for this field.\n */\nexport const signupValidator = vine.create({\n fullName: vine.string().nullable(),\n email: email().unique({ table: 'users', column: 'email' }),\n password: password().confirmed({\n confirmationField: 'passwordConfirmation',\n }),\n passwordConfirmation: vine.string(),\n})\n\n/**\n * Validator to use when logging in an existing user\n */\nexport const loginValidator = vine.create({\n email: email(),\n password: vine.string(),\n})\n\n/**\n * Validator for requesting a password-recovery e-mail.\n */\nexport const forgotPasswordValidator = vine.create({\n email: email(),\n})\n\n/**\n * Validator for choosing a new password from a recovery link.\n */\nexport const resetPasswordValidator = vine.create({\n password: password().confirmed({\n confirmationField: 'passwordConfirmation',\n }),\n passwordConfirmation: vine.string(),\n})\n\n/**\n * Validator for the self-service profile form.\n */\nexport const profileValidator = vine.create({\n fullName: vine.string().trim().minLength(2).maxLength(120),\n})\n\n/**\n * Validator for changing the password of the signed-in user. The current\n * password is verified by the controller against the stored hash.\n */\nexport const changePasswordValidator = vine.create({\n currentPassword: vine.string(),\n password: password().confirmed({\n confirmationField: 'passwordConfirmation',\n }),\n passwordConfirmation: vine.string(),\n})\n","config/ally.ts":"import env from '#start/env'\nimport { defineConfig, services } from '@adonisjs/ally'\n\n/**\n * Providers are always declared so their types exist; a provider is offered\n * to users only when its credentials are configured (see start/routes.ts).\n */\nconst allyConfig = defineConfig({\n github: services.github({\n clientId: env.get('GITHUB_CLIENT_ID') ?? '',\n clientSecret: env.get('GITHUB_CLIENT_SECRET') ?? '',\n callbackUrl: `${env.get('APP_URL')}/oauth/github/callback`,\n }),\n google: services.google({\n clientId: env.get('GOOGLE_CLIENT_ID') ?? '',\n clientSecret: env.get('GOOGLE_CLIENT_SECRET') ?? '',\n callbackUrl: `${env.get('APP_URL')}/oauth/google/callback`,\n }),\n})\n\nexport default allyConfig\n\ndeclare module '@adonisjs/ally/types' {\n interface SocialProviders extends InferSocialProviders<typeof allyConfig> {}\n}\n","config/app.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig } from '@adonisjs/core/http'\n\n/**\n * The app URL can be used in various places where you want to create absolute\n * URLs to your application. For example, when sending emails, images should\n * use absolute URLs.\n */\nexport const appUrl = env.get('APP_URL')\n\n/**\n * The configuration settings used by the HTTP server\n */\nexport const http = defineConfig({\n /**\n * Generate a unique request id for each incoming request.\n * Useful to correlate logs and debug a request flow.\n */\n generateRequestId: true,\n\n /**\n * Allow HTTP method spoofing via the \"_method\" form/query parameter.\n * This lets HTML forms target PUT/PATCH/DELETE routes while still\n * submitting with POST.\n */\n allowMethodSpoofing: false,\n\n /**\n * Enabling async local storage will let you access HTTP context\n * from anywhere inside your application.\n */\n useAsyncLocalStorage: false,\n\n /**\n * Redirect configuration controls the behavior of\n * response.redirect().back() and query string forwarding.\n */\n redirect: {\n /**\n * When enabled, all redirects automatically carry over the current\n * request's query string parameters to the redirect destination.\n * Use withQs(false) to opt out for a specific redirect.\n */\n forwardQueryString: true,\n },\n\n /**\n * Manage cookies configuration. The settings for the session id cookie are\n * defined inside the \"config/session.ts\" file.\n */\n cookie: {\n /**\n * Restrict the cookie to a specific domain.\n * Keep empty to use the current host.\n */\n domain: '',\n\n /**\n * Restrict the cookie to a URL path. '/' means all routes.\n */\n path: '/',\n\n /**\n * Default lifetime for cookies managed by the HTTP layer.\n */\n maxAge: '2h',\n\n /**\n * Prevent JavaScript access to the cookie in the browser.\n */\n httpOnly: true,\n\n /**\n * Send cookies only over HTTPS in production.\n */\n secure: app.inProduction,\n\n /**\n * Cross-site policy for cookie sending.\n */\n sameSite: 'lax',\n },\n})\n","config/attachment.ts":"import type { InferConverters } from '@jrmc/adonis-attachment/types/config'\nimport { defineConfig } from '@jrmc/adonis-attachment'\n// import sharp from 'sharp'\n\n/**\n * Documentation: https://adonis-attachment.jrmc.dev/guide/essentials/configuration\n */\n\nconst attachmentConfig = defineConfig({\n /**\n * Enable the preComputeUrl flag to pre compute the URLs after SELECT queries. (default: false)\n */\n // preComputeUrl: true,\n\n /**\n * Enable the meta informations after upload. (default: false)\n */\n // meta: true,\n\n /**\n * Enable file rename after upload. (default: true)\n */\n // rename: false,\n\n /**\n * Specify binary path\n */\n // bin: { // [!code focus:8]\n // ffmpegPath: 'ffmpeg_path', // the full path of the binary\n // ffprobePath: 'ffprobe_path', // the full path of the binary\n // pdftoppmPath: 'pdftoppm_path' // the full path of the binary\n // pdfinfoPath: 'pdfinfo_path' // the full path of the binary\n // sofficePath: 'soffice_path', // the full path of the binary (libreoffice/openoffice)\n // },\n\n /**\n * Queue configuration for file processing.\n * By default, 1 task is processed concurrently. A task corresponds to a model attribute.\n * For example, if a model has a logo attribute and an avatar attribute,\n * this represents 2 tasks, regardless of the number of concerts per attribute.\n *\n * Increasing concurrency can improve performance but consumes more resources.\n * A value too high may lead to memory or CPU issues.\n *\n */\n // queue: {\n // concurrency: 2\n // },\n\n /**\n * Maximum duration (in milliseconds) that an operation can take before being interrupted.\n * Default: 30_000 (30 seconds)\n *\n * This timeout applies to each individual operation conversion.\n * If an operation exceeds this time limit, it will be interrupted and an error will be thrown.\n *\n * Increase this value if you're processing large files or if your operations\n * require more time (e.g., long video conversion).\n *\n */\n // timeout: 40_000,\n\n /**\n * Configure how variants are stored relative to the original file.\n *\n * - 'basePath': Define a custom base path where all variants will be stored.\n * By default, variants are stored in the same folder as the original file.\n *\n * - 'ignoreFolder': When set to 'true', the variant will not include the parent\n * folder from the original attachment.\n */\n // variant: {\n // basePath: 'variants',\n // ignoreFolder: true,\n // },\n\n /**\n *\n */\n converters: {\n thumbnail: {\n /**\n * optional converter\n * default : @jrmc/adonis-attachment/converters/autodetect_converter\n * image : @jrmc/adonis-attachment/converters/image_converter\n * pdf : @jrmc/adonis-attachment/converters/pdf_thumbnail_converter\n * document : @jrmc/adonis-attachment/converters/document_thumbnail_converter\n * video : @jrmc/adonis-attachment/converters/video_thumbnail_converter\n * create your custom converter : https://adonis-attachment.jrmc.dev/guide/advanced_usage/custom-converter\n */\n // converter: () => import('@jrmc/adonis-attachment/converters/autodetect_converter'),\n\n /**\n *\n * https://sharp.pixelplumbing.com/api-resize/\n */\n resize: 300,\n\n // resize: { // https://sharp.pixelplumbing.com/api-resize\n // width: 400,\n // height: 400,\n // fit: sharp.fit.cover,\n // position: 'top'\n // },\n\n /**\n *\n * https://sharp.pixelplumbing.com/api-output/#toformat\n */\n // format: 'jpeg',\n // format: {\n // format: 'jpeg',\n // options: {\n // quality: 80\n // }\n // }\n\n /**\n *\n * https://sharp.pixelplumbing.com/api-operation/#autoorient\n */\n // autoOrient: false,\n\n /**\n * generation of blurhashes (default: true)\n * https://blurha.sh/\n */\n // blurhash: true,\n },\n },\n})\n\nexport default attachmentConfig\n\ndeclare module '@jrmc/adonis-attachment' {\n interface AttachmentVariants extends InferConverters<typeof attachmentConfig> {}\n}\n","config/auth.ts":"import { defineConfig } from '@adonisjs/auth'\nimport { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'\nimport type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'\n\nconst authConfig = defineConfig({\n /**\n * Default guard used when no guard is explicitly specified.\n */\n default: 'web',\n\n guards: {\n /**\n * Session-based guard for browser authentication.\n */\n web: sessionGuard({\n /**\n * Enable persistent login using remember-me tokens.\n */\n useRememberMeTokens: false,\n\n provider: sessionUserProvider({\n model: () => import('#models/user'),\n }),\n }),\n },\n})\n\nexport default authConfig\n\n/**\n * Inferring types from the configured auth\n * guards.\n */\ndeclare module '@adonisjs/auth/types' {\n export interface Authenticators extends InferAuthenticators<typeof authConfig> {}\n}\ndeclare module '@adonisjs/core/types' {\n interface EventsList extends InferAuthEvents<Authenticators> {}\n}\n","config/bodyparser.ts":"import { defineConfig } from '@adonisjs/core/bodyparser'\n\nconst bodyParserConfig = defineConfig({\n /**\n * Parse request bodies for these HTTP methods.\n * Keep this aligned with methods that receive payloads in your routes.\n */\n allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],\n\n /**\n * Config for the \"application/x-www-form-urlencoded\"\n * content-type parser.\n */\n form: {\n /**\n * Normalize empty string values to null.\n */\n convertEmptyStringsToNull: true,\n\n /**\n * Content types handled by the form parser.\n */\n types: ['application/x-www-form-urlencoded'],\n },\n\n /**\n * Config for the JSON parser.\n */\n json: {\n /**\n * Normalize empty string values to null.\n */\n convertEmptyStringsToNull: true,\n\n /**\n * Content types handled by the JSON parser.\n */\n types: [\n 'application/json',\n 'application/json-patch+json',\n 'application/vnd.api+json',\n 'application/csp-report',\n ],\n },\n\n /**\n * Config for the \"multipart/form-data\" content-type parser.\n * File uploads are handled by the multipart parser.\n */\n multipart: {\n /**\n * Automatically process uploaded files into the system tmp directory.\n */\n autoProcess: true,\n\n /**\n * Normalize empty string values to null.\n */\n convertEmptyStringsToNull: true,\n\n /**\n * Routes where multipart processing is handled manually.\n */\n processManually: [],\n\n /**\n * Maximum accepted payload size for multipart requests.\n */\n limit: '20mb',\n\n /**\n * Content types handled by the multipart parser.\n */\n types: ['multipart/form-data'],\n },\n})\n\nexport default bodyParserConfig\n","config/cache.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, store, drivers } from '@adonisjs/cache'\nimport type { InferStores } from '@adonisjs/cache/types'\n\nconst cacheConfig = defineConfig({\n default: 'redis',\n prefix: app.inTest ? `${env.get('ADULA_NAMESPACE')}-test` : env.get('ADULA_NAMESPACE'),\n ttl: '5m',\n stores: {\n redis: store()\n .useL1Layer(drivers.memory())\n .useL2Layer(drivers.redis({ connectionName: 'main' })),\n },\n})\nexport default cacheConfig\ndeclare module '@adonisjs/cache/types' {\n interface CacheStores extends InferStores<typeof cacheConfig> {}\n}\n","config/cors.ts":"import app from '@adonisjs/core/services/app'\nimport { defineConfig } from '@adonisjs/cors'\n\n/**\n * Configuration options to tweak the CORS policy. The following\n * options are documented on the official documentation website.\n *\n * https://docs.adonisjs.com/guides/security/cors\n */\nconst corsConfig = defineConfig({\n /**\n * Enable or disable CORS handling globally.\n */\n enabled: true,\n\n /**\n * In development, allow every origin to simplify local front/backend setup.\n * In production, keep an explicit allowlist (empty by default, so no\n * cross-origin browser access is allowed until configured).\n */\n origin: app.inDev ? true : [],\n\n /**\n * HTTP methods accepted for cross-origin requests.\n */\n methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],\n\n /**\n * Reflect request headers by default. Use a string array to restrict\n * allowed headers.\n */\n headers: true,\n\n /**\n * Response headers exposed to the browser.\n */\n exposeHeaders: [],\n\n /**\n * Allow cookies/authorization headers on cross-origin requests.\n */\n credentials: true,\n\n /**\n * Cache CORS preflight response for N seconds.\n */\n maxAge: 90,\n})\n\nexport default corsConfig\n","config/database.ts":"import env from '#start/env'\nimport { defineConfig } from '@adonisjs/lucid'\nimport { modules } from '#start/modules'\n\nexport default defineConfig({\n connection: 'postgres',\n connections: {\n postgres: {\n client: 'pg',\n connection: {\n host: env.get('DB_HOST'),\n port: env.get('DB_PORT'),\n user: env.get('DB_USER'),\n password: env.get('DB_PASSWORD'),\n database: env.get('DB_DATABASE'),\n },\n pool: { min: 0, max: 10 },\n migrations: {\n naturalSort: true,\n paths: [\n 'database/migrations',\n 'node_modules/@adula/kit/build/database/migrations',\n ...modules.map(\n (module) =>\n `app/modules/${module.name}/migrations`\n ),\n ],\n },\n },\n },\n})\n","config/drive.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, services } from '@adonisjs/drive'\n\n/**\n * Files are private on every disk: downloads are served only through the\n * authorized attachment route, never by the disk itself. Paths stored in the\n * database are relative to the disk root so `adula:storage:migrate` can move\n * the files between disks without rewriting records.\n */\nconst driveConfig = defineConfig({\n default: env.get('DRIVE_DISK'),\n services: {\n local: services.fs({\n location: app.makePath('storage/uploads'),\n serveFiles: false,\n visibility: 'private',\n }),\n s3: services.s3({\n credentials: {\n accessKeyId: env.get('AWS_ACCESS_KEY_ID') ?? '',\n secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY') ?? '',\n },\n region: env.get('AWS_REGION') ?? 'auto',\n endpoint: env.get('AWS_ENDPOINT'),\n bucket: env.get('S3_BUCKET') ?? '',\n visibility: 'private',\n // Bucket policies and IAM control access when Object Ownership disables ACLs.\n supportsACL: false,\n }),\n // Test-only second filesystem disk: `adula:storage:migrate` is verified against a real move.\n ...(app.inTest\n ? {\n local_test_archive: services.fs({\n location: app.makePath('storage/uploads-test-archive'),\n serveFiles: false,\n visibility: 'private',\n }),\n }\n : {}),\n },\n})\n\nexport default driveConfig\n\ndeclare module '@adonisjs/drive/types' {\n export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}\n}\n","config/encryption.ts":"import env from '#start/env'\nimport { defineConfig, drivers } from '@adonisjs/core/encryption'\n\nconst encryptionConfig = defineConfig({\n /**\n * Default encryption driver used by the application.\n */\n default: 'gcm',\n\n list: {\n gcm: drivers.aes256gcm({\n /**\n * Keys used for encryption/decryption.\n * First key encrypts, all keys are tried for decryption.\n */\n keys: [env.get('APP_KEY')],\n\n /**\n * Stable identifier for this driver.\n */\n id: 'gcm',\n }),\n },\n})\n\nexport default encryptionConfig\n\n/**\n * Inferring types for the list of encryptors you have configured\n * in your application.\n */\ndeclare module '@adonisjs/core/types' {\n export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}\n}\n","config/hash.ts":"import { defineConfig, drivers } from '@adonisjs/core/hash'\n\n/**\n * Hashing configuration.\n *\n * This starter uses Node.js scrypt under the hood.\n * Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback\n */\nconst hashConfig = defineConfig({\n /**\n * Default hasher used by the application.\n */\n default: 'scrypt',\n\n list: {\n /**\n * Scrypt is memory-hard, which makes brute-force attacks more expensive.\n */\n scrypt: drivers.scrypt({\n /**\n * Work factor (Node alias: N / cost).\n * Higher values increase security and CPU+memory usage.\n *\n * Tuning guideline:\n * - Start with 16384.\n * - Increase gradually (for example 32768) and benchmark login/signup latency.\n * - Keep values practical for your slowest production machine.\n *\n * Node constraint: value must be a power of two greater than 1.\n */\n cost: 16384,\n\n /**\n * Block size (Node alias: r / blockSize).\n * Increases memory and CPU linearly.\n *\n * Tuning guideline:\n * - Keep 8 unless you have a measured reason to change it.\n * - Raise only with benchmark data, because memory usage grows quickly.\n */\n blockSize: 8,\n\n /**\n * Parallelization (Node alias: p / parallelization).\n * Controls how many independent computations are performed.\n *\n * Tuning guideline:\n * - Keep 1 for most applications.\n * - Increase only after load testing if your infrastructure benefits from it.\n */\n parallelization: 1,\n\n /**\n * Maximum memory limit in bytes (Node alias: maxmem / maxMemory).\n * Hashing throws if the estimated memory usage is above this limit.\n * Node documents the check as approximately: 128 * N * r > maxmem.\n *\n * Tuning guideline:\n * - Keep this aligned with your cost/blockSize choices.\n * - Increase carefully on memory-constrained environments.\n */\n maxMemory: 33554432,\n }),\n },\n})\n\nexport default hashConfig\n\n/**\n * Inferring types for the list of hashers you have configured\n * in your application.\n */\ndeclare module '@adonisjs/core/types' {\n export interface HashersList extends InferHashers<typeof hashConfig> {}\n}\n","config/inertia.ts":"import { defineConfig } from '@adonisjs/inertia'\n\nconst inertiaConfig = defineConfig({\n /**\n * Server-side rendering options.\n */\n ssr: {\n /**\n * Toggle SSR mode for Inertia pages.\n */\n enabled: false,\n\n /**\n * Entry file used by the SSR server build.\n */\n entrypoint: 'inertia/ssr.tsx',\n },\n})\n\nexport default inertiaConfig\n","config/limiter.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, stores } from '@adonisjs/limiter'\n\n/**\n * Tests always use the in-process store so parallel suites never share\n * counters and a suite can reset them between tests; LIMITER_STORE only\n * selects the store outside tests.\n */\nconst limiterConfig = defineConfig({\n default: app.inTest ? 'memory' : (env.get('LIMITER_STORE') ?? 'redis'),\n stores: {\n redis: stores.redis({ connectionName: 'main', keyPrefix: `${env.get('ADULA_NAMESPACE')}:limiter` }),\n memory: stores.memory({}),\n },\n})\n\nexport default limiterConfig\n\ndeclare module '@adonisjs/limiter/types' {\n export interface LimitersList extends InferLimiters<typeof limiterConfig> {}\n}\n","config/logger.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'\n\nconst loggerConfig = defineConfig({\n /**\n * Default logger name used by ctx.logger and app logger calls.\n */\n default: 'app',\n\n loggers: {\n app: {\n /**\n * Toggle this logger on/off.\n */\n enabled: true,\n\n /**\n * Logger name shown in log records.\n */\n name: env.get('APP_NAME'),\n\n /**\n * Minimum level to output (trace, debug, info, warn, error, fatal).\n */\n level: env.get('LOG_LEVEL'),\n\n /**\n * Use sync destination in non-production for immediate flush.\n */\n destination: !app.inProduction ? await syncDestination() : undefined,\n\n /**\n * Configure where logs are written.\n */\n transport: {\n targets: [targets.file({ destination: 1 })],\n },\n },\n },\n})\n\nexport default loggerConfig\n\n/**\n * Inferring types for the list of loggers you have configured\n * in your application.\n */\ndeclare module '@adonisjs/core/types' {\n export interface LoggersList extends InferLoggers<typeof loggerConfig> {}\n}\n","config/mail.ts":"import env from '#start/env'\nimport { defineConfig, transports } from '@adonisjs/mail'\n\nconst smtpUser = env.get('SMTP_USERNAME')\nconst smtpPassword = env.get('SMTP_PASSWORD')\n\n/**\n * SMTP only: no external mail service. Tests fake the mailer, and a local\n * relay on 127.0.0.1:1025 is the development default.\n */\nconst mailConfig = defineConfig({\n default: env.get('MAIL_MAILER') ?? 'smtp',\n from: {\n address: env.get('MAIL_FROM_ADDRESS') ?? 'no-reply@localhost',\n name: env.get('MAIL_FROM_NAME') ?? 'عدولة',\n },\n mailers: {\n smtp: transports.smtp({\n host: env.get('SMTP_HOST') ?? '127.0.0.1',\n port: env.get('SMTP_PORT') ?? 1025,\n secure: env.get('SMTP_SECURE') ?? env.get('SMTP_PORT') === 465,\n requireTLS: env.get('SMTP_REQUIRE_TLS') ?? env.get('NODE_ENV') === 'production',\n connectionTimeout: 10000,\n greetingTimeout: 10000,\n socketTimeout: 20000,\n ...(smtpUser && smtpPassword\n ? { auth: { type: 'login' as const, user: smtpUser, pass: smtpPassword } }\n : {}),\n }),\n },\n})\n\nexport default mailConfig\n\ndeclare module '@adonisjs/mail/types' {\n export interface MailersList extends InferMailers<typeof mailConfig> {}\n}\n","config/mcp.ts":"import env from '#start/env'\nimport { defineConfig } from '@jrmc/adonis-mcp'\n\nexport default defineConfig({\n name: env.get('ADULA_NAMESPACE'),\n version: '0.1.0',\n cache: { tools: { ttlMs: 0, scope: 'private' } },\n})\n","config/queue.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig } from '@nemoventures/adonis-jobs'\nimport type { InferQueues } from '@nemoventures/adonis-jobs/types'\n\nconst queueConfig = defineConfig({\n connection: { connectionName: 'main' },\n // Own connections keep cache/Redis shutdown from interrupting queue cleanup.\n useSharedConnection: false,\n defaultPrefix: app.inTest ? `${env.get('ADULA_NAMESPACE')}-test` : env.get('ADULA_NAMESPACE'),\n defaultQueue: 'events',\n healthCheck: { enabled: false },\n queues: {\n events: {\n defaultWorkerOptions: { concurrency: 4 },\n defaultJobOptions: {\n attempts: 10,\n backoff: { type: 'exponential', delay: 1000 },\n removeOnComplete: { age: 604800 },\n removeOnFail: false,\n },\n },\n },\n})\nexport default queueConfig\ndeclare module '@nemoventures/adonis-jobs/types' {\n interface Queues extends InferQueues<typeof queueConfig> {}\n}\n","config/redis.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig } from '@adonisjs/redis'\nimport type { InferConnections } from '@adonisjs/redis/types'\n\nconst redisConfig = defineConfig({\n connection: 'main',\n connections: {\n main: {\n host: env.get('REDIS_HOST'),\n port: env.get('REDIS_PORT'),\n password: env.get('REDIS_PASSWORD'),\n // Parallel test runs isolate themselves with REDIS_TEST_DB (default 15).\n db: app.inTest ? Number(process.env.REDIS_TEST_DB ?? 15) : 0,\n keyPrefix: '',\n maxRetriesPerRequest: null,\n retryStrategy: (attempt) => (attempt > 10 ? null : attempt * 100),\n },\n },\n})\nexport default redisConfig\ndeclare module '@adonisjs/redis/types' {\n interface RedisConnections extends InferConnections<typeof redisConfig> {}\n}\n","config/session.ts":"import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, stores } from '@adonisjs/session'\n\nconst sessionConfig = defineConfig({\n /**\n * Enable or disable session support globally.\n */\n enabled: true,\n\n /**\n * Cookie name storing the session identifier.\n */\n cookieName: 'adonis-session',\n\n /**\n * When set to true, the session id cookie will be deleted\n * once the user closes the browser.\n */\n clearWithBrowser: false,\n\n /**\n * Define how long to keep the session data alive without\n * any activity.\n */\n age: '2h',\n\n /**\n * Configuration for session cookie and the\n * cookie store.\n */\n cookie: {\n /**\n * Restrict the cookie to a URL path. '/' means all routes.\n */\n path: '/',\n\n /**\n * Prevent JavaScript access to the cookie in the browser.\n */\n httpOnly: true,\n\n /**\n * Send cookies only over HTTPS in production.\n */\n secure: app.inProduction,\n\n /**\n * Cross-site policy for cookie sending.\n */\n sameSite: 'lax',\n },\n\n /**\n * The store to use. Make sure to validate the environment\n * variable in order to infer the store name without any\n * errors.\n */\n // The official Japa session client persists into the memory store.\n store: app.inTest ? 'memory' : env.get('SESSION_DRIVER'),\n\n /**\n * List of configured stores. Refer documentation to see\n * list of available stores and their config.\n */\n stores: {\n /**\n * Store session data inside encrypted cookies.\n */\n cookie: stores.cookie(),\n\n /**\n * Store session data inside the configured database.\n */\n database: stores.database(),\n },\n})\n\nexport default sessionConfig\n","config/shield.ts":"import { defineConfig } from '@adonisjs/shield'\n\nconst shieldConfig = defineConfig({\n /**\n * Configure CSP policies for your app. Refer documentation\n * to learn more.\n */\n csp: {\n /**\n * Enable the Content-Security-Policy header.\n */\n enabled: false,\n\n /**\n * Per-resource CSP directives.\n */\n directives: {},\n\n /**\n * Report violations without blocking resources.\n */\n reportOnly: false,\n },\n\n /**\n * Configure CSRF protection options. Refer documentation\n * to learn more.\n */\n csrf: {\n /**\n * Enable CSRF token verification for state-changing requests.\n */\n enabled: true,\n\n /**\n * Route patterns to exclude from CSRF checks.\n * Useful for external webhooks or API endpoints.\n */\n exceptRoutes: [],\n\n /**\n * Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients.\n */\n enableXsrfCookie: true,\n\n /**\n * HTTP methods protected by CSRF validation.\n */\n methods: ['POST', 'PUT', 'PATCH', 'DELETE'],\n },\n\n /**\n * Control how your website should be embedded inside\n * iframes.\n */\n xFrame: {\n /**\n * Enable the X-Frame-Options header.\n */\n enabled: true,\n\n /**\n * Block all framing attempts. Default value is DENY.\n */\n action: 'DENY',\n },\n\n /**\n * Force browser to always use HTTPS.\n */\n hsts: {\n /**\n * Enable the Strict-Transport-Security header.\n */\n enabled: true,\n\n /**\n * HSTS policy duration remembered by browsers.\n */\n maxAge: '180 days',\n },\n\n /**\n * Disable browsers from sniffing content types and rely only\n * on the response content-type header.\n */\n contentTypeSniffing: {\n /**\n * Enable X-Content-Type-Options: nosniff.\n */\n enabled: true,\n },\n})\n\nexport default shieldConfig\n","config/static.ts":"import { defineConfig } from '@adonisjs/static'\n\n/**\n * Configuration options to tweak the static files middleware.\n * The complete set of options are documented on the\n * official documentation website.\n *\n * https://docs.adonisjs.com/guides/basics/static-file-server\n */\nconst staticServerConfig = defineConfig({\n /**\n * Enable or disable static file serving middleware.\n */\n enabled: true,\n\n /**\n * Generate ETag headers for client/proxy caching.\n */\n etag: true,\n\n /**\n * Include Last-Modified headers for conditional requests.\n */\n lastModified: true,\n\n /**\n * Policy for files starting with a dot.\n */\n dotFiles: 'ignore',\n})\n\nexport default staticServerConfig\n","config/vite.ts":"import { defineConfig } from '@adonisjs/vite'\n\nconst viteBackendConfig = defineConfig({\n /**\n * The output of vite will be written inside this\n * directory. The path should be relative from\n * the application root.\n */\n buildDirectory: 'public/assets',\n\n /**\n * The path to the manifest file generated by the\n * \"vite build\" command.\n */\n manifestFile: 'public/assets/.vite/manifest.json',\n\n /**\n * Feel free to change the value of the \"assetsUrl\" to\n * point to a CDN in production.\n */\n assetsUrl: '/assets',\n\n /**\n * HTML attributes added to generated script tags.\n */\n scriptAttributes: {\n /**\n * Execute scripts after HTML parsing is complete.\n */\n defer: true,\n },\n})\n\nexport default viteBackendConfig\n","database/migrations/1761885935168_create_users_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n protected tableName = 'users'\n\n async up() {\n this.schema.createTable(this.tableName, (table) => {\n table.increments('id').notNullable()\n table.string('full_name').nullable()\n table.string('email', 254).notNullable().unique()\n table.string('password').notNullable()\n\n table.timestamp('created_at').notNullable()\n table.timestamp('updated_at').nullable()\n })\n }\n\n async down() {\n this.schema.dropTable(this.tableName)\n }\n}\n","database/migrations/1770000000500_create_sessions_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\nexport default class extends BaseSchema {\n async up() {\n this.schema.createTable('sessions', (table) => {\n table.string('id').primary()\n table.text('data').notNullable()\n table.timestamp('expires_at').notNullable().index()\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","database/migrations/1789700000000_users_lifecycle.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\n/** Additive: a disabled user keeps their rows; only sign-in and sessions stop. */\nexport default class extends BaseSchema {\n async up() {\n this.schema.alterTable('users', (table) => {\n table.timestamp('disabled_at', { useTz: true }).nullable()\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","database/migrations/1789700001000_create_password_reset_tokens_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\n/** Single-use, hashed recovery tokens; the raw token only ever travels inside the e-mail. */\nexport default class extends BaseSchema {\n async up() {\n this.schema.createTable('password_reset_tokens', (table) => {\n table.increments('id')\n table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE')\n table.string('token_hash', 64).notNullable().unique()\n table.timestamp('expires_at', { useTz: true }).notNullable()\n table.timestamp('used_at', { useTz: true }).nullable()\n table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())\n table.index(['user_id'])\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","database/migrations/1789700002000_create_user_sessions_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\n/**\n * One row per browser session of a signed-in user. The id is the session\n * store id, so revoking a row can also delete its \"sessions\" row at once.\n */\nexport default class extends BaseSchema {\n async up() {\n this.schema.createTable('user_sessions', (table) => {\n table.string('id', 255).primary()\n table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE')\n table.string('ip', 64).nullable()\n table.string('user_agent', 512).nullable()\n table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())\n table.timestamp('last_seen_at', { useTz: true }).notNullable().defaultTo(this.now())\n table.timestamp('revoked_at', { useTz: true }).nullable()\n table.index(['user_id'])\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","database/migrations/1789700003000_create_social_accounts_table.ts":"import { BaseSchema } from '@adonisjs/lucid/schema'\n\n/** Links an OAuth identity (provider + provider id) to exactly one local user. */\nexport default class extends BaseSchema {\n async up() {\n this.schema.createTable('social_accounts', (table) => {\n table.increments('id')\n table.string('provider', 32).notNullable()\n table.string('provider_id', 255).notNullable()\n table.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE')\n table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())\n table.unique(['provider', 'provider_id'])\n table.index(['user_id'])\n })\n }\n async down() {\n throw new Error('Use an expand/contract migration')\n }\n}\n","inertia/app.tsx":"import { type ReactElement } from 'react'\nimport { client } from './client'\nimport Layout from '~/layouts/default'\nimport { type Data } from '@generated/data'\nimport { createRoot } from 'react-dom/client'\nimport { createInertiaApp, type ResolvedComponent } from '@inertiajs/react'\nimport { TuyauProvider } from '@adonisjs/inertia/react'\nimport { resolvePageComponent } from '@adonisjs/inertia/helpers'\nimport './css/kit.css'\n\nconst appName = import.meta.env.VITE_APP_NAME || 'adula kit'\n\ncreateInertiaApp({\n title: (title) => (title ? `${title} - ${appName}` : appName),\n resolve: (name) => {\n return resolvePageComponent<ResolvedComponent>(\n `./pages/${name}.tsx`,\n import.meta.glob<ResolvedComponent>('./pages/**/*.tsx'),\n (page: ReactElement<Data.SharedProps>) => <Layout children={page} />\n )\n },\n setup({ el, App, props }) {\n createRoot(el).render(\n <TuyauProvider client={client}>\n <App {...props} />\n </TuyauProvider>\n )\n },\n progress: {\n color: '#4B5563',\n },\n})\n","inertia/client.ts":"import { registry } from '@generated/registry'\nimport { createTuyau } from '@tuyau/core/client'\n\nexport const client = createTuyau({\n baseUrl: '/',\n registry,\n})\n\nexport const urlFor = client.urlFor\n","inertia/components/account-menu.tsx":"import { usePage } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { ChevronsUpDown, LogOut, MonitorSmartphone, UserRound } from 'lucide-react'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '~/components/ui/dropdown-menu'\n\n/** The user block at the bottom of the workspace sidebar: profile, sessions, logout. */\nexport function AccountMenu() {\n const { user } = usePage<{ user?: { fullName: string | null; email: string } }>().props\n if (!user) return null\n return (\n <DropdownMenu>\n <DropdownMenuTrigger\n aria-label=\"قائمة الحساب\"\n className=\"flex w-full items-center gap-3 rounded-lg p-1 text-start outline-none hover:bg-background focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n >\n <span className=\"grid size-9 shrink-0 place-items-center rounded-full bg-secondary font-semibold\">\n {user.fullName?.slice(0, 1) || 'م'}\n </span>\n <span className=\"min-w-0 flex-1\">\n <strong className=\"block truncate text-xs\">{user.fullName || 'حسابي'}</strong>\n <span className=\"block truncate text-[10px] text-muted-foreground\" dir=\"ltr\">\n {user.email}\n </span>\n </span>\n <ChevronsUpDown size={14} className=\"shrink-0 text-muted-foreground\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" className=\"w-56\">\n <DropdownMenuLabel className=\"truncate text-xs text-muted-foreground\" dir=\"ltr\">\n {user.email}\n </DropdownMenuLabel>\n <DropdownMenuSeparator />\n <DropdownMenuItem asChild>\n <Link route=\"profile.show\">\n <UserRound />\n الملف الشخصي\n </Link>\n </DropdownMenuItem>\n <DropdownMenuItem asChild>\n <Link route=\"account_sessions.index\">\n <MonitorSmartphone />\n الجلسات\n </Link>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <Form route=\"session.destroy\">\n <DropdownMenuItem asChild variant=\"destructive\">\n <button type=\"submit\" className=\"w-full\">\n <LogOut />\n تسجيل الخروج\n </button>\n </DropdownMenuItem>\n </Form>\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n","inertia/components/admin-nav.tsx":"import type { ReactNode } from 'react'\nimport { router, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport {\n Activity,\n History,\n KeyRound,\n Network,\n Settings,\n ShieldCheck,\n UserCog,\n Users,\n} from 'lucide-react'\nimport { calendarDisplay } from '~/components/ui/calendar_date'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { Button } from '~/components/ui/button'\n\nexport const adminLinks = [\n { href: '/admin/setup', label: 'الإعداد الأولي', icon: Settings },\n { href: '/admin/users', label: 'المستخدمون', icon: Users },\n { href: '/admin/roles', label: 'الأدوار والصلاحيات', icon: ShieldCheck },\n { href: '/admin/org-units', label: 'الهيكل التنظيمي', icon: Network },\n { href: '/admin/activity', label: 'سجل النشاط', icon: History },\n { href: '/admin/jobs', label: 'تشغيل النظام', icon: Activity },\n { href: '/admin/settings', label: 'الإعدادات', icon: Settings },\n { href: '/admin/sessions', label: 'الجلسات', icon: KeyRound },\n]\n\nexport function AdminNav() {\n const page = usePage<{ isAdmin?: boolean }>()\n if (!page.props.isAdmin) return null\n return (\n <>\n <p className=\"mb-3 mt-8 px-7 text-[11px] font-semibold text-muted-foreground\">الإدارة</p>\n <nav aria-label=\"التنقل الإداري\" className=\"space-y-1 px-4\">\n {adminLinks.map(({ href, label, icon: Icon }) => {\n const active = page.url.startsWith(href)\n return (\n <Link\n key={href}\n href={href}\n aria-current={active ? 'page' : undefined}\n className={`flex items-center gap-3 rounded-lg px-4 py-2.5 text-sm transition-colors ${active ? 'bg-accent font-semibold text-primary' : 'text-muted-foreground hover:bg-background hover:text-foreground'}`}\n >\n <Icon size={17} strokeWidth={1.6} />\n {label}\n </Link>\n )\n })}\n </nav>\n </>\n )\n}\n\nexport function ImpersonationBar() {\n const page = usePage<{\n impersonating?: boolean\n user?: { fullName: string | null; email: string }\n }>()\n if (!page.props.impersonating) return null\n return (\n <div\n role=\"status\"\n className=\"flex flex-wrap items-center justify-between gap-3 border-b border-amber-300 bg-amber-50 px-5 py-2 text-sm text-amber-900 lg:px-10\"\n >\n <span className=\"flex items-center gap-2\">\n <UserCog size={16} />\n أنت تتصفح باسم {page.props.user?.fullName || page.props.user?.email}\n </span>\n <Button size=\"sm\" variant=\"outline\" onClick={() => router.post('/impersonation/stop')}>\n إنهاء الانتحال\n </Button>\n </div>\n )\n}\n\nexport function AdminHeader({\n title,\n description,\n children,\n}: {\n title: string\n description?: string\n children?: ReactNode\n}) {\n return (\n <div className=\"mb-8 flex flex-wrap items-start justify-between gap-4\">\n <div>\n <div className=\"mb-2 flex items-center gap-2 text-xs text-muted-foreground\">\n <ShieldCheck size={15} />\n <span>الإدارة</span>\n </div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">{title}</h1>\n {description && <p className=\"mt-3 text-sm text-muted-foreground\">{description}</p>}\n </div>\n {children && <div className=\"flex flex-wrap gap-2 pt-3\">{children}</div>}\n </div>\n )\n}\n\nexport function useDateTimeFormatter() {\n const { calendar } = useUiPreferences()\n return (value: string | null | undefined) => calendarDisplay(value, calendar, true)\n}\n\nexport function formatAge(ms: number | null) {\n if (ms === null) return 'لا يوجد'\n const seconds = Math.round(ms / 1000)\n if (seconds < 60) return `قبل ${seconds} ثانية`\n const minutes = Math.round(seconds / 60)\n if (minutes < 60) return `قبل ${minutes} دقيقة`\n const hours = Math.round(minutes / 60)\n if (hours < 48) return `قبل ${hours} ساعة`\n return `قبل ${Math.round(hours / 24)} يوماً`\n}\n","inertia/components/backup-banner.tsx":"import { usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { TriangleAlert } from 'lucide-react'\n\n/** Shown to every administrator until a complete offsite backup is younger than 48 hours. */\nexport function BackupBanner() {\n const page = usePage<{ backupWarning?: boolean }>()\n if (!page.props.backupWarning) return null\n return (\n <div\n role=\"alert\"\n className=\"flex flex-wrap items-center gap-3 bg-destructive px-5 py-3 text-sm text-white lg:px-10\"\n >\n <TriangleAlert size={18} />\n <span className=\"font-semibold\">لا توجد نسخة احتياطية خارجية سليمة خلال آخر 48 ساعة.</span>\n <Link href=\"/admin/jobs\" className=\"underline underline-offset-4\">\n راجع صفحة تشغيل النظام\n </Link>\n </div>\n )\n}\n","inertia/components/flash-messages.tsx":"import { useEffect } from 'react'\nimport { usePage } from '@inertiajs/react'\nimport { toast } from 'sonner'\n\nexport function FlashMessages() {\n const { flash } = usePage()\n useEffect(() => {\n const inline = [...document.querySelectorAll('[data-flash-message]')].map((element) =>\n element.getAttribute('data-flash-message')\n )\n if (typeof flash.error === 'string' && !inline.includes(flash.error))\n toast.error(flash.error, { id: 'flash-error' })\n if (typeof flash.success === 'string' && !inline.includes(flash.success))\n toast.success(flash.success, { id: 'flash-success' })\n }, [flash])\n return null\n}\n","inertia/components/mail-test.tsx":"import { useEffect, useState } from 'react'\nimport { router } from '@inertiajs/react'\nimport type { MailTestState } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle, CardDescription } from '~/components/ui/card'\nimport {\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogFooter,\n} from '~/components/ui/dialog'\n\nexport type MailTestProps = {\n mailTest: Omit<MailTestState, 'fingerprint'> | null\n mailRecipient: string\n}\nconst labels = {\n sending: 'جارٍ الإرسال',\n pending: 'بانتظار تأكيد الاستلام',\n confirmed: 'أكد المدير وصول الرسالة',\n not_received: 'أفاد المدير بعدم وصول الرسالة',\n failed: 'تعذّر الإرسال',\n}\nexport function MailTest({\n mailTest,\n mailRecipient,\n returnTo = 'settings',\n}: MailTestProps & { returnTo?: 'settings' | 'setup' }) {\n const [busy, setBusy] = useState(false)\n const [open, setOpen] = useState(false)\n const pending =\n mailTest?.status === 'pending' && Date.now() - Date.parse(mailTest.requestedAt) < 86_400_000\n useEffect(() => {\n setOpen(pending)\n }, [mailTest?.id, pending])\n const post = (path: string, data: Record<string, string | boolean> = {}) => {\n setBusy(true)\n router.post(\n path,\n { ...data, returnTo },\n { preserveScroll: true, onFinish: () => setBusy(false) }\n )\n }\n return (\n <Card className=\"mb-6\">\n <CardHeader>\n <CardTitle>اختبار البريد الإلكتروني</CardTitle>\n <CardDescription>\n قبول الإرسال لا يثبت الوصول. نحتاج تأكيدك بعد مراجعة صندوق بريدك والبريد غير المرغوب.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <p>\n المستلم: <span dir=\"ltr\">{mailRecipient}</span>\n </p>\n <p role=\"status\">\n {mailTest?.status === 'pending' && !pending\n ? 'انتهت مهلة التأكيد؛ أرسل تجربة جديدة'\n : mailTest\n ? labels[mailTest.status]\n : 'لم يُختبر البريد بهذه الإعدادات'}\n </p>\n {mailTest && (\n <p className=\"text-xs text-muted-foreground\">\n وقت التجربة: <time dir=\"ltr\">{mailTest.requestedAt}</time> · المرجع:{' '}\n <span dir=\"ltr\">{mailTest.id}</span>\n </p>\n )}\n {mailTest?.status === 'not_received' && (\n <p className=\"text-sm\">\n راجع عنوان المرسل وإعدادات SMTP والبريد غير المرغوب، ثم أرسل تجربة جديدة.\n </p>\n )}\n <div className=\"flex flex-wrap gap-3\">\n <Button disabled={busy} onClick={() => post('/admin/settings/mail/test')}>\n {busy ? 'جارٍ التنفيذ…' : 'إرسال بريد تجريبي'}\n </Button>\n {pending && (\n <Button variant=\"outline\" disabled={busy} onClick={() => setOpen(true)}>\n تأكيد استلام البريد\n </Button>\n )}\n </div>\n {pending && (\n <p className=\"text-sm\">\n هل وصلت الرسالة؟ افتح تأكيد الاستلام لتسجيل النتيجة. ينتهي التأكيد بعد 24 ساعة.\n </p>\n )}\n </CardContent>\n <Dialog mode=\"confirm\" open={open} onOpenChange={setOpen}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>هل وصل البريد التجريبي؟</DialogTitle>\n <DialogDescription>\n تحقق من رسالة «تجربة البريد — تأكيد الاستلام» المرسلة إلى {mailRecipient}، وأن مرجعها{' '}\n {mailTest?.id}. تأكيدك يخص هذه التجربة فقط.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <Button\n disabled={busy}\n onClick={() =>\n post('/admin/settings/mail/confirm', { id: mailTest!.id, received: true })\n }\n >\n وصلت الرسالة\n </Button>\n <Button\n variant=\"outline\"\n disabled={busy}\n onClick={() =>\n post('/admin/settings/mail/confirm', { id: mailTest!.id, received: false })\n }\n >\n لم تصل الرسالة\n </Button>\n <Button variant=\"ghost\" disabled={busy} onClick={() => setOpen(false)}>\n سأتحقق لاحقًا\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Card>\n )\n}\n","inertia/components/notification-bell.tsx":"import { usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Bell } from 'lucide-react'\n\nexport function NotificationBell() {\n const page = usePage<{ unreadNotifications?: number; user?: { email: string } }>()\n if (!page.props.user) return null\n const count = page.props.unreadNotifications ?? 0\n return (\n <Link\n href=\"/notifications\"\n aria-label={count ? `الإشعارات، ${count} غير مقروء` : 'الإشعارات'}\n className=\"relative grid size-9 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-background hover:text-foreground\"\n >\n <Bell size={18} strokeWidth={1.6} />\n {count > 0 && (\n <span\n data-testid=\"unread-count\"\n className=\"absolute -end-0.5 -top-0.5 min-w-4 rounded-full bg-destructive px-1 text-center text-[10px] font-semibold leading-4 text-white\"\n >\n {count}\n </span>\n )}\n </Link>\n )\n}\n","inertia/components/ui-settings.tsx":"import { useState } from 'react'\nimport { router } from '@inertiajs/react'\nimport type { UiPreferences, CalendarPreference } from '@adula/kit'\nimport { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/card'\nimport { Button } from '~/components/ui/button'\nimport { Label } from '~/components/ui/label'\nimport { Switch } from '~/components/ui/switch'\nimport { Badge } from '~/components/ui/badge'\nimport { ResourceSelect } from '~/components/ui/resource-field'\n\nexport function UiSettings({ initial }: { initial: UiPreferences }) {\n const [value, setValue] = useState(initial)\n const [saving, setSaving] = useState(false)\n return (\n <Card className=\"mb-8\">\n <CardHeader>\n <CardTitle>التواريخ وتجربة الاستخدام</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <div className=\"max-w-md space-y-2\">\n <Label htmlFor=\"calendar-preference\">عرض التاريخ</Label>\n <ResourceSelect\n id=\"calendar-preference\"\n aria-label=\"عرض التاريخ\"\n value={value.calendar}\n options={[\n { value: 'gregory', label: 'الميلادي' },\n { value: 'islamic-umalqura', label: 'الهجري · أم القرى' },\n { value: 'both', label: 'الميلادي والهجري معًا' },\n ]}\n onChange={(calendar) =>\n setValue({ ...value, calendar: calendar as CalendarPreference })\n }\n />\n <p className=\"text-xs text-muted-foreground\">\n يشمل الجداول والتفاصيل والنماذج. عند اختيار كليهما يمكنك الإدخال بأي تقويم مع رؤية\n التاريخ المقابل.\n </p>\n </div>\n <div className=\"flex items-center justify-between gap-6\">\n <div>\n <Label htmlFor=\"confirm-dialog-close\">تأكيد إغلاق نماذج التحرير</Label>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n يحمي الإدخال عند النقر خارج النافذة أو الضغط على إغلاق أو Escape. نوافذ العرض تُغلق\n مباشرة.\n </p>\n </div>\n <Switch\n id=\"confirm-dialog-close\"\n checked={value.confirmDialogClose}\n onCheckedChange={(confirmDialogClose) => setValue({ ...value, confirmDialogClose })}\n />\n </div>\n <div className=\"flex items-center justify-between gap-6\">\n <Label htmlFor=\"page-transitions\">انتقالات سلسة بين الصفحات</Label>\n <Switch\n id=\"page-transitions\"\n checked={value.pageTransitions}\n onCheckedChange={(pageTransitions) => setValue({ ...value, pageTransitions })}\n />\n </div>\n <div className=\"flex items-start justify-between gap-6 border-t pt-5\">\n <div>\n <p className=\"text-sm font-medium\">حماية الوصول الإداري</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n تغييرات الصلاحيات تتطلب تأكيدًا. يمنع النظام إزالة آخر مدير نشط أو سحب إدارة النظام من\n حسابك الحالي.\n </p>\n </div>\n <Badge variant=\"secondary\" className=\"shrink-0\">\n مفعّلة دائمًا\n </Badge>\n </div>\n <Button\n disabled={saving}\n onClick={() =>\n router.put(\n '/admin/settings',\n {\n key: 'ui.preferences',\n scope: 'system',\n scopeId: '0',\n value: JSON.stringify(value),\n },\n {\n preserveScroll: true,\n onStart: () => setSaving(true),\n onFinish: () => setSaving(false),\n }\n )\n }\n >\n {saving ? 'جارٍ الحفظ…' : 'حفظ تفضيلات الواجهة'}\n </Button>\n </CardContent>\n </Card>\n )\n}\n","inertia/css/app.css":"/* adula:legacy-layer */\n@layer theme, base, legacy, components, utilities;\n@layer legacy {\n @scope ([data-adula-legacy]) {\n :scope {\n --gray-1: oklch(98.5% 0 0);\n --gray-2: oklch(97% 0 0);\n --gray-3: oklch(92.2% 0 0);\n --gray-4: oklch(87% 0 0);\n --gray-6: oklch(55.6% 0 0);\n --gray-7: oklch(43.9% 0 0);\n --gray-8: oklch(37.1% 0 0);\n --gray-10: oklch(26.9% 0 0);\n --gray-12: oklch(14.5% 0 0);\n }\n\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n :scope {\n height: 100%;\n font-family: system-ui, sans-serif;\n -webkit-font-smoothing: antialiased;\n background: var(--gray-2);\n color: var(--gray-10);\n font-size: 16px;\n line-height: 1.5;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n [x-cloak] {\n display: none;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n color: var(--gray-12);\n }\n\n /* Header */\n header {\n max-width: 1440px;\n margin: auto;\n padding: 0 30px;\n }\n header > div {\n display: flex;\n justify-content: space-between;\n align-items: center;\n height: 64px;\n }\n header nav {\n display: flex;\n align-items: center;\n gap: 26px;\n }\n header nav a {\n font-weight: 500;\n color: var(--gray-8);\n }\n header nav a:hover,\n header nav a.current {\n color: var(--gray-12);\n }\n\n /* Main */\n main {\n max-width: 1440px;\n margin: 0 30px;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n min-height: calc(100vh - 65px);\n background: #fff;\n border: 1px solid var(--gray-3);\n }\n\n .hero {\n padding: 100px 50px;\n max-width: 880px;\n }\n .hero h1 {\n margin-bottom: 15px;\n font-size: 52px;\n font-weight: 600;\n letter-spacing: -1px;\n line-height: 1.05;\n }\n .hero p {\n font-size: 22px;\n color: var(--gray-7);\n }\n .hero .button {\n margin-top: 30px;\n display: inline-block;\n padding: 10px 16px;\n }\n\n .cards {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n padding: 0 50px;\n border-top: 1px solid var(--gray-3);\n }\n .cards a {\n padding: 30px 40px;\n border-right: 1px solid var(--gray-3);\n }\n .cards a:first-child {\n border-left: 1px solid var(--gray-3);\n }\n .cards a:hover {\n background: var(--gray-1);\n }\n .cards h3 {\n margin-bottom: 10px;\n font-size: 20px;\n font-weight: 600;\n letter-spacing: -0.4px;\n }\n .cards p {\n color: var(--gray-6);\n }\n\n /* Form */\n .form-container {\n display: flex;\n flex-direction: column;\n justify-content: center;\n max-width: 400px;\n margin: auto;\n }\n .form-container h1 {\n font-size: 32px;\n letter-spacing: -0.5px;\n margin: 5px 0;\n }\n .form-container p {\n font-size: 18px;\n margin-bottom: 48px;\n color: var(--gray-6);\n }\n form {\n display: flex;\n flex-direction: column;\n gap: 24px;\n }\n label {\n margin-bottom: 4px;\n display: block;\n font-size: 14px;\n font-weight: 500;\n }\n input,\n textarea,\n button {\n width: 100%;\n border-radius: 4px;\n font: inherit;\n }\n input {\n height: 40px;\n border: 1px solid var(--gray-4);\n padding: 0 16px;\n }\n input[data-invalid='true'],\n textarea[data-invalid='true'] {\n border-color: #fb2c36;\n }\n input[data-invalid='true'] + div,\n textarea[data-invalid='true'] + div {\n color: #fb2c36;\n font-size: 14px;\n font-weight: 500;\n margin-top: 2px;\n }\n\n button {\n background: var(--gray-12);\n color: #fff;\n border: none;\n padding: 10px;\n font-weight: 500;\n }\n button:hover {\n background: var(--gray-10);\n }\n\n /* Alerts */\n .alert {\n background: #fff;\n position: relative;\n padding: 12px 16px;\n font-size: 14px;\n min-width: 380px;\n font-weight: 500;\n border: 1px solid var(--gray-3);\n border-radius: 10px;\n animation: scale-up 0.2s cubic-bezier(0.39, 0.575, 0.565, 1) both;\n }\n .alert-destructive {\n color: #fb2c36;\n background: #fb2c361a;\n border-color: #fb2c36;\n }\n .alert-success {\n color: #00a63e;\n background: #00a63e1a;\n border-color: #00a63e;\n }\n .flash-container {\n position: fixed;\n top: 80px;\n left: 0;\n right: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n }\n\n @keyframes scale-up {\n from {\n transform: scale(0.7);\n }\n to {\n transform: scale(1);\n }\n }\n }\n}\n","inertia/layouts/default.tsx":"import { type Data } from '@generated/data'\nimport { toast, Toaster } from 'sonner'\nimport { usePage } from '@inertiajs/react'\nimport { type ReactElement, useEffect } from 'react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { ImpersonationBar } from '~/components/admin-nav'\n\nexport default function Layout({ children }: { children: ReactElement<Data.SharedProps> }) {\n const { url, flash } = usePage()\n useEffect(() => {\n toast.dismiss()\n }, [url])\n\n useEffect(() => {\n if (typeof flash.error === 'string') {\n toast.error(flash.error)\n }\n if (typeof flash.success === 'string') {\n toast.success(flash.success)\n }\n })\n\n return (\n <div data-adula-legacy>\n {/* Impersonation must be visible on every layout, not only the workspace shell. */}\n <ImpersonationBar />\n <header>\n <div>\n <div>\n <Link route=\"home\" aria-label=\"adula kit — الرئيسية\">\n <strong dir=\"ltr\">adula kit</strong>\n </Link>\n </div>\n <div>\n <nav>\n {children.props.user ? (\n <>\n <span>{children.props.user.initials}</span>\n <Form route=\"session.destroy\">\n <button type=\"submit\">تسجيل الخروج</button>\n </Form>\n </>\n ) : (\n <>\n <Link route=\"new_account.create\">إنشاء حساب</Link>\n <Link route=\"session.create\">الدخول</Link>\n </>\n )}\n </nav>\n </div>\n </div>\n </header>\n <main>{children}</main>\n <Toaster position=\"top-center\" richColors />\n </div>\n )\n}\n","inertia/layouts/workspace.tsx":"import type { ReactNode } from 'react'\nimport { usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { ClipboardList, LayoutDashboard, ArrowUpLeft } from 'lucide-react'\nimport type { ResourceNavigation } from '@adula/kit'\nimport { Toaster } from '~/components/ui/sonner'\nimport { AccountMenu } from '~/components/account-menu'\nimport { AdminNav, ImpersonationBar, adminLinks } from '~/components/admin-nav'\nimport { FlashMessages } from '~/components/flash-messages'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { BackupBanner } from '~/components/backup-banner'\nimport { NotificationBell } from '~/components/notification-bell'\n\nexport default function Workspace({ children }: { children: ReactNode }) {\n const preferences = useUiPreferences()\n const page = usePage<{\n user?: { fullName: string | null; email: string }\n navigation: ResourceNavigation\n canInviteUsers?: boolean\n }>()\n const navigation = [\n { href: '/', label: 'نظرة عامة', icon: LayoutDashboard },\n ...(page.props.canInviteUsers\n ? [{ href: '/users/invite', label: 'دعوة مستخدم', icon: ClipboardList }]\n : []),\n ...(page.props.navigation ?? []).map((entry) => ({ ...entry, icon: ClipboardList })),\n ]\n const current =\n [...navigation, ...adminLinks].find(\n (entry) => entry.href !== '/' && page.url.startsWith(entry.href)\n )?.label ?? 'نظرة عامة'\n return (\n <div dir=\"rtl\" className=\"min-h-screen bg-background text-foreground\" data-workspace-shell>\n <FlashMessages />\n <aside className=\"fixed inset-y-0 start-0 z-30 hidden w-[244px] flex-col overflow-y-auto border-e border-border bg-white lg:flex [&>*]:shrink-0\">\n <Link href=\"/\" className=\"flex h-24 items-center gap-3 px-7\">\n <span className=\"grid size-10 place-items-center rounded-xl bg-primary text-2xl font-bold text-white\">\n ع\n </span>\n <span>\n <strong className=\"block text-xl tracking-tight\">عدولة</strong>\n <span className=\"text-[11px] text-muted-foreground\">مساحة أعمالك، بوضوح</span>\n </span>\n </Link>\n <div className=\"mx-5 mb-8 rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium\">\n مساحة العمل\n <span className=\"mt-1 block text-xs font-normal text-muted-foreground\">\n التطبيق المرجعي\n </span>\n </div>\n <p className=\"mb-3 px-7 text-[11px] font-semibold text-muted-foreground\">العمل اليومي</p>\n <nav aria-label=\"التنقل الرئيسي\" className=\"space-y-1 px-4\">\n {navigation.map(({ href, label, icon: Icon }) => {\n const active = href !== '/' ? page.url.startsWith(href) : page.url === '/'\n return (\n <Link\n key={href}\n href={href}\n aria-current={active ? 'page' : undefined}\n className={`flex items-center gap-3 rounded-lg px-4 py-3 text-sm transition-colors ${active ? 'bg-accent font-semibold text-primary' : 'text-muted-foreground hover:bg-background hover:text-foreground'}`}\n >\n <Icon size={19} strokeWidth={1.6} />\n {label}\n </Link>\n )\n })}\n </nav>\n <AdminNav />\n <div className=\"mx-5 mb-6 mt-auto border-t border-border pt-5\">\n <AccountMenu />\n <Link\n href=\"/\"\n className=\"mt-4 flex items-center justify-between text-xs text-muted-foreground\"\n >\n الصفحة الرئيسية\n <ArrowUpLeft size={14} />\n </Link>\n </div>\n </aside>\n <div className=\"lg:ps-[244px]\">\n <div className=\"flex h-[72px] items-center justify-between border-b border-border bg-white/80 px-5 lg:px-10\">\n <div className=\"flex items-center gap-3 text-xs text-muted-foreground\">\n <span className=\"font-semibold text-foreground lg:hidden\">عدولة</span>\n <span>مساحة العمل</span>\n <span>/</span>\n <span className=\"text-foreground\">{current}</span>\n </div>\n <span className=\"flex items-center gap-3 text-[11px] text-muted-foreground\">\n <NotificationBell />\n <span className=\"size-1.5 rounded-full bg-primary\" />\n بيئة التجربة\n </span>\n </div>\n <BackupBanner />\n <ImpersonationBar />\n <nav\n aria-label=\"التنقل على الجوال\"\n className=\"flex gap-5 overflow-auto border-b border-border bg-white px-5 py-3 text-xs lg:hidden\"\n >\n {navigation.map((item) => (\n <Link key={item.href} href={item.href}>\n {item.label}\n </Link>\n ))}\n </nav>\n <main className=\"mx-auto max-w-[1440px] p-5 lg:px-10 lg:py-9\">\n <div\n key={page.url.split('?')[0]}\n className={preferences.pageTransitions ? 'adula-page-enter' : undefined}\n >\n {children}\n </div>\n </main>\n </div>\n <Toaster position=\"bottom-left\" richColors />\n </div>\n )\n}\n","inertia/pages/account/profile.tsx":"import { Head, usePage } from '@inertiajs/react'\nimport { Form } from '@adonisjs/inertia/react'\nimport type { ReactElement } from 'react'\nimport { CircleCheck, KeyRound, OctagonX, UserRound } from 'lucide-react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Alert, AlertDescription } from '~/components/ui/alert'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\nimport Workspace from '~/layouts/workspace'\n\nexport default function Profile() {\n const { props, flash } = usePage<{ user?: { fullName: string | null; email: string } }>()\n const user = props.user\n return (\n <>\n <Head title=\"الملف الشخصي\" />\n <div className=\"mb-8\">\n <div className=\"mb-2 flex items-center gap-2 text-xs text-muted-foreground\">\n <UserRound size={15} />\n <span>حسابي</span>\n </div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">الملف الشخصي</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n اسمك كما يظهر للزملاء، وكلمة المرور التي تحمي حسابك.\n </p>\n </div>\n\n {typeof flash.success === 'string' && (\n <Alert className=\"mb-6\" data-flash-message={flash.success}>\n <CircleCheck />\n <AlertDescription>{flash.success}</AlertDescription>\n </Alert>\n )}\n {typeof flash.error === 'string' && (\n <Alert variant=\"destructive\" className=\"mb-6\" data-flash-message={flash.error}>\n <OctagonX />\n <AlertDescription>{flash.error}</AlertDescription>\n </Alert>\n )}\n\n <div className=\"grid gap-6 lg:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>البيانات الأساسية</CardTitle>\n <CardDescription>البريد الإلكتروني هو معرّف الدخول ولا يتغير من هنا.</CardDescription>\n </CardHeader>\n <CardContent>\n <Form route=\"profile.update\" className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input id=\"email\" dir=\"ltr\" value={user?.email ?? ''} readOnly disabled />\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"fullName\">الاسم الكامل</Label>\n <Input\n id=\"fullName\"\n name=\"fullName\"\n defaultValue={user?.fullName ?? ''}\n autoComplete=\"name\"\n required\n aria-invalid={errors.fullName ? true : undefined}\n />\n {errors.fullName && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.fullName}\n </p>\n )}\n </div>\n <Button type=\"submit\" disabled={processing}>\n حفظ البيانات\n </Button>\n </>\n )}\n </Form>\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <KeyRound size={16} />\n كلمة المرور\n </CardTitle>\n <CardDescription>\n تغيير كلمة المرور يُنهي جلساتك الأخرى على بقية الأجهزة.\n </CardDescription>\n </CardHeader>\n <CardContent>\n <Form route=\"profile.password\" className=\"space-y-5\" resetOnSuccess>\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"currentPassword\">كلمة المرور الحالية</Label>\n <Input\n type=\"password\"\n id=\"currentPassword\"\n name=\"currentPassword\"\n dir=\"ltr\"\n autoComplete=\"current-password\"\n required\n aria-invalid={errors.currentPassword ? true : undefined}\n />\n {errors.currentPassword && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.currentPassword}\n </p>\n )}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"password\">كلمة المرور الجديدة</Label>\n <Input\n type=\"password\"\n id=\"password\"\n name=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.password ? true : undefined}\n />\n {errors.password && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.password}\n </p>\n )}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"passwordConfirmation\">تأكيد كلمة المرور الجديدة</Label>\n <Input\n type=\"password\"\n id=\"passwordConfirmation\"\n name=\"passwordConfirmation\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.passwordConfirmation ? true : undefined}\n />\n {errors.passwordConfirmation && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.passwordConfirmation}\n </p>\n )}\n </div>\n <Button type=\"submit\" variant=\"outline\" disabled={processing}>\n تغيير كلمة المرور\n </Button>\n </>\n )}\n </Form>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\nProfile.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/account/sessions.tsx":"import { Head, usePage } from '@inertiajs/react'\nimport { Form } from '@adonisjs/inertia/react'\nimport type { ReactElement } from 'react'\nimport { CircleCheck, MonitorSmartphone, OctagonX } from 'lucide-react'\nimport type { UserSession } from '#services/sessions'\nimport { Button } from '~/components/ui/button'\nimport { Badge } from '~/components/ui/badge'\nimport { Alert, AlertDescription } from '~/components/ui/alert'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\nimport Workspace from '~/layouts/workspace'\nimport { useDateTimeFormatter } from '~/components/admin-nav'\n\ntype Props = { sessions: UserSession[]; currentSessionId: string }\n\n/** A short human label for a user-agent string; the raw value stays in the title. */\nexport function describeAgent(agent: string | null) {\n if (!agent) return 'جهاز غير معروف'\n const browser = /Edg\\//.test(agent)\n ? 'Edge'\n : /OPR\\//.test(agent)\n ? 'Opera'\n : /Firefox\\//.test(agent)\n ? 'Firefox'\n : /Chrome\\//.test(agent)\n ? 'Chrome'\n : /Safari\\//.test(agent)\n ? 'Safari'\n : 'متصفح'\n const os = /Windows/.test(agent)\n ? 'Windows'\n : /iPhone|iPad/.test(agent)\n ? 'iOS'\n : /Android/.test(agent)\n ? 'Android'\n : /Mac OS/.test(agent)\n ? 'macOS'\n : /Linux/.test(agent)\n ? 'Linux'\n : 'نظام غير معروف'\n return `${browser} على ${os}`\n}\n\nexport default function Sessions({ sessions, currentSessionId }: Props) {\n const formatWhen = useDateTimeFormatter()\n const { flash } = usePage()\n const others = sessions.filter((session) => session.id !== currentSessionId)\n return (\n <>\n <Head title=\"الجلسات\" />\n <div className=\"mb-8 flex flex-wrap items-start justify-between gap-4\">\n <div>\n <div className=\"mb-2 flex items-center gap-2 text-xs text-muted-foreground\">\n <MonitorSmartphone size={15} />\n <span>حسابي</span>\n </div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">الجلسات</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n الأجهزة التي سجّلت الدخول منها. أنهِ أي جلسة لا تعرفها فوراً.\n </p>\n </div>\n <Form route=\"account_sessions.purge\" className=\"pt-3\">\n {({ processing }) => (\n <Button type=\"submit\" variant=\"outline\" disabled={processing || !others.length}>\n إنهاء الجلسات الأخرى ({others.length})\n </Button>\n )}\n </Form>\n </div>\n\n {typeof flash.success === 'string' && (\n <Alert className=\"mb-6\" data-flash-message={flash.success}>\n <CircleCheck />\n <AlertDescription>{flash.success}</AlertDescription>\n </Alert>\n )}\n {typeof flash.error === 'string' && (\n <Alert variant=\"destructive\" className=\"mb-6\" data-flash-message={flash.error}>\n <OctagonX />\n <AlertDescription>{flash.error}</AlertDescription>\n </Alert>\n )}\n\n <section\n className=\"overflow-hidden rounded-xl border border-border bg-white shadow-[0_2px_10px_#1c302804]\"\n aria-label=\"قائمة الجلسات\"\n >\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الجهاز</TableHead>\n <TableHead>العنوان</TableHead>\n <TableHead>آخر نشاط</TableHead>\n <TableHead>بدأت</TableHead>\n <TableHead className=\"text-start\">إجراء</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {sessions.map((session) => {\n const current = session.id === currentSessionId\n return (\n <TableRow key={session.id}>\n <TableCell title={session.userAgent ?? undefined}>\n <span className=\"flex flex-wrap items-center gap-2\">\n {describeAgent(session.userAgent)}\n {current && <Badge>الجلسة الحالية</Badge>}\n </span>\n </TableCell>\n <TableCell dir=\"ltr\" className=\"text-start tabular-nums\">\n {session.ip ?? '—'}\n </TableCell>\n <TableCell>{formatWhen(session.lastSeenAt)}</TableCell>\n <TableCell>{formatWhen(session.createdAt)}</TableCell>\n <TableCell>\n <Form route=\"account_sessions.destroy\" routeParams={{ id: session.id }}>\n {({ processing }) => (\n <Button\n type=\"submit\"\n size=\"sm\"\n variant={current ? 'destructive' : 'outline'}\n disabled={processing}\n aria-label={current ? 'إنهاء هذه الجلسة' : 'إنهاء الجلسة'}\n >\n {current ? 'إنهاء هذه الجلسة' : 'إنهاء'}\n </Button>\n )}\n </Form>\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </Table>\n </section>\n </>\n )\n}\nSessions.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/activity/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport axios from 'axios'\nimport { Filter } from 'lucide-react'\nimport type { ActivityPage } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader, useDateTimeFormatter } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Filters = { resource: string; action: string; actorId: string; from: string; to: string }\ntype Props = {\n activity: ActivityPage\n facets: { resources: string[]; actions: string[] }\n filters: Filters\n}\nconst select = 'h-9 w-full rounded-md border border-input bg-white px-2 text-sm'\n\nexport default function ActivityIndex({ activity, facets, filters }: Props) {\n const formatDateTime = useDateTimeFormatter()\n const [draft, setDraft] = useState<Filters>(filters)\n const [rows, setRows] = useState(activity.data)\n const [cursor, setCursor] = useState(activity.nextCursor)\n const [loading, setLoading] = useState(false)\n const set = (key: keyof Filters, value: string) =>\n setDraft((current) => ({ ...current, [key]: value }))\n const apply = (event: FormEvent) => {\n event.preventDefault()\n const query = Object.fromEntries(Object.entries(draft).filter(([, value]) => value))\n router.get('/admin/activity', query, { preserveState: false })\n }\n const more = async () => {\n if (!cursor) return\n setLoading(true)\n try {\n const response = await axios.get<ActivityPage>('/admin/activity', {\n params: { ...filters, cursor },\n headers: { Accept: 'application/json' },\n })\n setRows((current) => [...current, ...response.data.data])\n setCursor(response.data.nextCursor)\n } finally {\n setLoading(false)\n }\n }\n return (\n <>\n <Head title=\"سجل النشاط\" />\n <AdminHeader\n title=\"سجل النشاط\"\n description=\"كل تغيير على السجلات والإدارة يُكتب داخل معاملته.\"\n />\n <form\n onSubmit={apply}\n className=\"mb-6 grid gap-3 rounded-xl border border-border bg-white p-5 md:grid-cols-6\"\n >\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-resource\">الكيان</Label>\n <select\n id=\"filter-resource\"\n className={select}\n value={draft.resource}\n onChange={(event) => set('resource', event.target.value)}\n >\n <option value=\"\">الكل</option>\n {facets.resources.map((resource) => (\n <option key={resource} value={resource}>\n {resource}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-action\">الإجراء</Label>\n <select\n id=\"filter-action\"\n className={select}\n value={draft.action}\n onChange={(event) => set('action', event.target.value)}\n >\n <option value=\"\">الكل</option>\n {facets.actions.map((action) => (\n <option key={action} value={action}>\n {action}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-actor\">معرّف المنفّذ</Label>\n <Input\n id=\"filter-actor\"\n type=\"number\"\n min={1}\n value={draft.actorId}\n onChange={(event) => set('actorId', event.target.value)}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-from\">من تاريخ</Label>\n <Input\n id=\"filter-from\"\n type=\"date\"\n value={draft.from}\n onChange={(event) => set('from', event.target.value)}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"filter-to\">إلى تاريخ</Label>\n <Input\n id=\"filter-to\"\n type=\"date\"\n value={draft.to}\n onChange={(event) => set('to', event.target.value)}\n />\n </div>\n <Button type=\"submit\" variant=\"outline\" className=\"self-end\">\n <Filter size={15} />\n تصفية\n </Button>\n </form>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الوقت</TableHead>\n <TableHead>الكيان</TableHead>\n <TableHead>السجل</TableHead>\n <TableHead>الإجراء</TableHead>\n <TableHead>المنفّذ</TableHead>\n <TableHead>التغييرات</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.id}>\n <TableCell className=\"whitespace-nowrap text-xs\">\n {formatDateTime(row.createdAt)}\n </TableCell>\n <TableCell>{row.resource}</TableCell>\n <TableCell>{row.recordId}</TableCell>\n <TableCell>{row.action}</TableCell>\n <TableCell dir=\"ltr\" className=\"text-xs\">\n {row.actor ?? row.actorId}\n </TableCell>\n <TableCell>\n <code className=\"line-clamp-2 max-w-md text-xs\" dir=\"ltr\">\n {JSON.stringify(row.changes)}\n </code>\n </TableCell>\n </TableRow>\n ))}\n {rows.length === 0 && (\n <TableRow>\n <TableCell colSpan={6} className=\"py-10 text-center text-muted-foreground\">\n لا نشاط مطابق.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n {cursor && (\n <div className=\"border-t border-border p-4 text-center\">\n <Button variant=\"outline\" onClick={more} disabled={loading}>\n {loading ? 'جارٍ التحميل…' : 'تحميل المزيد'}\n </Button>\n </div>\n )}\n </section>\n </>\n )\n}\nActivityIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/forbidden.tsx":"import type { ReactElement } from 'react'\nimport { Head } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { ShieldOff } from 'lucide-react'\nimport Workspace from '~/layouts/workspace'\nimport { Button } from '~/components/ui/button'\n\nexport default function Forbidden() {\n return (\n <>\n <Head title=\"غير مصرح\" />\n <div className=\"mx-auto max-w-md py-20 text-center\">\n <span className=\"mx-auto mb-6 grid size-14 place-items-center rounded-full bg-secondary text-primary\">\n <ShieldOff size={26} />\n </span>\n <h1 className=\"text-2xl font-semibold\">هذه المنطقة للمديرين فقط</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n حسابك لا يملك صلاحية الإدارة الكاملة. تواصل مع مدير النظام إن كنت تحتاجها.\n </p>\n <Button asChild variant=\"outline\" className=\"mt-8\">\n <Link href=\"/\">العودة إلى الرئيسية</Link>\n </Button>\n </div>\n </>\n )\n}\nForbidden.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/jobs/index.tsx":"import type { ReactElement, ReactNode } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { RotateCcw } from 'lucide-react'\nimport type { QueueSnapshot, RuntimeHealth } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader, formatAge, useDateTimeFormatter } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Badge } from '~/components/ui/badge'\nimport { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/card'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { health: RuntimeHealth; queues: QueueSnapshot[] }\n\nfunction Stat({\n title,\n badge,\n children,\n}: {\n title: string\n badge?: ReactNode\n children: ReactNode\n}) {\n return (\n <Card>\n <CardHeader className=\"flex flex-row items-center justify-between\">\n <CardTitle className=\"text-sm\">{title}</CardTitle>\n {badge}\n </CardHeader>\n <CardContent className=\"text-sm text-muted-foreground\">{children}</CardContent>\n </Card>\n )\n}\nconst Health = ({ healthy, ok, bad }: { healthy: boolean; ok: string; bad: string }) => (\n <Badge variant={healthy ? 'default' : 'destructive'}>{healthy ? ok : bad}</Badge>\n)\n\nexport default function JobsIndex({ health, queues }: Props) {\n const formatDateTime = useDateTimeFormatter()\n const counts: [keyof QueueSnapshot['counts'], string][] = [\n ['waiting', 'بانتظار'],\n ['active', 'قيد التنفيذ'],\n ['delayed', 'مؤجلة'],\n ['failed', 'فاشلة'],\n ['completed', 'مكتملة'],\n ]\n return (\n <>\n <Head title=\"تشغيل النظام\" />\n <AdminHeader\n title=\"تشغيل النظام\"\n description=\"حالة الخدمات والمهام الخلفية، العمليات المتعثرة والنسخ الاحتياطي.\"\n />\n <div className=\"mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-5\">\n <Stat\n title=\"المجدول\"\n badge={<Health healthy={health.heartbeats.scheduler.healthy} ok=\"سليم\" bad=\"متوقف\" />}\n >\n آخر نبضة: {formatAge(health.heartbeats.scheduler.ageMs)}\n </Stat>\n <Stat\n title=\"العامل\"\n badge={<Health healthy={health.heartbeats.worker.healthy} ok=\"سليم\" bad=\"متوقف\" />}\n >\n آخر نبضة: {formatAge(health.heartbeats.worker.ageMs)}\n </Stat>\n <Stat\n title=\"صندوق الصادر\"\n badge={\n <Health\n healthy={health.outbox.backlog === 0}\n ok=\"فارغ\"\n bad={`${health.outbox.backlog} معلّق`}\n />\n }\n >\n أقدم حدث غير منشور: {formatAge(health.outbox.oldestAgeMs)}\n </Stat>\n <Stat title=\"الأحداث المعالجة\">{health.processedEvents} حدث بلا تكرار</Stat>\n <Stat\n title=\"النسخ الاحتياطي الخارجي\"\n badge={<Health healthy={!health.backup.stale} ok=\"حديث\" bad=\"متأخر\" />}\n >\n آخر نسخة: {formatDateTime(health.backup.lastOffsite)}\n <br />\n آخر اختبار استعادة: {formatDateTime(health.backup.lastRestoreTest)}\n </Stat>\n </div>\n {queues.map((queue) => (\n <section\n key={queue.name}\n className=\"mb-8 overflow-hidden rounded-xl border border-border bg-white\"\n >\n <div className=\"flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4\">\n <h2 className=\"font-semibold\">\n الطابور <span dir=\"ltr\">{queue.name}</span>\n </h2>\n <span className=\"flex flex-wrap gap-2 text-xs\">\n {counts.map(([key, label]) => (\n <Badge\n key={key}\n variant={key === 'failed' && queue.counts[key] > 0 ? 'destructive' : 'secondary'}\n >\n {label}: {queue.counts[key]}\n </Badge>\n ))}\n </span>\n </div>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>المعرّف</TableHead>\n <TableHead>الوظيفة</TableHead>\n <TableHead>المحاولات</TableHead>\n <TableHead>سبب الفشل</TableHead>\n <TableHead>وقت الفشل</TableHead>\n <TableHead className=\"w-32\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {queue.failed.map((job) => (\n <TableRow key={job.id}>\n <TableCell dir=\"ltr\" className=\"text-xs\">\n {job.id}\n </TableCell>\n <TableCell dir=\"ltr\">{job.name}</TableCell>\n <TableCell>{job.attemptsMade}</TableCell>\n <TableCell>\n <code className=\"line-clamp-2 max-w-md text-xs\" dir=\"ltr\">\n {job.failedReason}\n </code>\n </TableCell>\n <TableCell className=\"text-xs\">{formatDateTime(job.failedAt)}</TableCell>\n <TableCell>\n <Button\n size=\"sm\"\n variant=\"outline\"\n aria-label={`إعادة محاولة ${job.id}`}\n onClick={() =>\n router.post(`/admin/jobs/${job.id}/retry`, {}, { preserveScroll: true })\n }\n >\n <RotateCcw size={14} />\n إعادة المحاولة\n </Button>\n </TableCell>\n </TableRow>\n ))}\n {queue.failed.length === 0 && (\n <TableRow>\n <TableCell colSpan={6} className=\"py-8 text-center text-muted-foreground\">\n لا وظائف فاشلة.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </section>\n ))}\n </>\n )\n}\nJobsIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/notifications/index.tsx":"import { useEffect, useState, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport axios from 'axios'\nimport { BellOff, CheckCheck } from 'lucide-react'\nimport type { NotificationPage } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { useDateTimeFormatter } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\n\ntype Props = { notifications: NotificationPage }\n\nexport default function NotificationsIndex({ notifications }: Props) {\n const formatDateTime = useDateTimeFormatter()\n const [rows, setRows] = useState(notifications.data)\n const [cursor, setCursor] = useState(notifications.nextCursor)\n const [loading, setLoading] = useState(false)\n useEffect(() => {\n setRows(notifications.data)\n setCursor(notifications.nextCursor)\n }, [notifications])\n const more = async () => {\n if (!cursor) return\n setLoading(true)\n try {\n const response = await axios.get<NotificationPage>('/notifications', {\n params: { cursor },\n headers: { Accept: 'application/json' },\n })\n setRows((current) => [...current, ...response.data.data])\n setCursor(response.data.nextCursor)\n } finally {\n setLoading(false)\n }\n }\n return (\n <>\n <Head title=\"الإشعارات\" />\n <div className=\"mb-8 flex flex-wrap items-start justify-between gap-4\">\n <div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">الإشعارات</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n {notifications.unread\n ? `${notifications.unread} إشعار غير مقروء`\n : 'لا إشعارات غير مقروءة'}\n </p>\n </div>\n <Button\n variant=\"outline\"\n disabled={notifications.unread === 0}\n onClick={() => router.post('/notifications/read-all', {}, { preserveScroll: true })}\n >\n <CheckCheck size={16} />\n تعيين الكل كمقروء\n </Button>\n </div>\n <ul className=\"space-y-3\">\n {rows.map((item) => (\n <li\n key={item.id}\n className={`flex flex-wrap items-start justify-between gap-3 rounded-xl border bg-white px-5 py-4 ${item.readAt ? 'border-border' : 'border-primary/40 shadow-[0_2px_10px_#1c302808]'}`}\n >\n <div className=\"min-w-0 flex-1\">\n <p className=\"flex items-center gap-2 font-semibold\">\n {!item.readAt && <span className=\"size-2 rounded-full bg-primary\" />}\n {item.title}\n </p>\n <p className=\"mt-1 text-sm text-muted-foreground\">{item.body}</p>\n <p className=\"mt-2 text-xs text-muted-foreground\">{formatDateTime(item.createdAt)}</p>\n </div>\n {!item.readAt && (\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() =>\n router.post(`/notifications/${item.id}/read`, {}, { preserveScroll: true })\n }\n >\n تعيين كمقروء\n </Button>\n )}\n </li>\n ))}\n {rows.length === 0 && (\n <li className=\"flex flex-col items-center gap-3 rounded-xl border border-dashed border-border p-12 text-muted-foreground\">\n <BellOff size={26} />\n لا إشعارات بعد.\n </li>\n )}\n </ul>\n {cursor && (\n <div className=\"mt-6 text-center\">\n <Button variant=\"outline\" onClick={more} disabled={loading}>\n {loading ? 'جارٍ التحميل…' : 'تحميل المزيد'}\n </Button>\n </div>\n )}\n </>\n )\n}\nNotificationsIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/org_units/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { CornerDownLeft, Pencil, Plus, Trash2 } from 'lucide-react'\nimport type { OrgUnitNode } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Badge } from '~/components/ui/badge'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\n\ntype Props = { units: OrgUnitNode[] }\ntype Draft = { parentId: number | null; name: string; type: string }\nconst select = 'h-9 rounded-md border border-input bg-white px-2 text-sm'\n\nexport default function OrgUnitsIndex({ units }: Props) {\n const [creating, setCreating] = useState<Draft | null>(null)\n const [renaming, setRenaming] = useState<OrgUnitNode | null>(null)\n const [renameTo, setRenameTo] = useState('')\n const [deleting, setDeleting] = useState<OrgUnitNode | null>(null)\n const [targets, setTargets] = useState<Record<number, string>>({})\n const options = { preserveScroll: true, preserveState: true }\n const submitCreate = (event: FormEvent) => {\n event.preventDefault()\n if (!creating) return\n router.post('/admin/org-units', creating, { ...options, onSuccess: () => setCreating(null) })\n }\n const submitRename = (event: FormEvent) => {\n event.preventDefault()\n if (!renaming) return\n router.patch(\n `/admin/org-units/${renaming.id}`,\n { name: renameTo },\n { ...options, onSuccess: () => setRenaming(null) }\n )\n }\n const move = (unit: OrgUnitNode) => {\n const chosen = targets[unit.id] ?? String(unit.parentId ?? '')\n router.post(`/admin/org-units/${unit.id}/move`, { parentId: chosen || null }, options)\n }\n const candidates = (unit: OrgUnitNode) =>\n units.filter((other) => other.id !== unit.id && !other.path.startsWith(`${unit.path}.`))\n return (\n <>\n <Head title=\"الهيكل التنظيمي\" />\n <AdminHeader\n title=\"الهيكل التنظيمي\"\n description=\"شجرة واحدة لكل المستويات؛ نقل وحدة يحدّث نطاق كل ما تحتها فوراً.\"\n >\n <Button onClick={() => setCreating({ parentId: null, name: '', type: 'department' })}>\n <Plus size={16} />\n إضافة وحدة\n </Button>\n </AdminHeader>\n <ul className=\"space-y-2\">\n {units.map((unit) => (\n <li\n key={unit.id}\n className=\"flex flex-wrap items-center gap-3 rounded-xl border border-border bg-white px-4 py-3\"\n style={{ marginInlineStart: `${(unit.depth - 1) * 24}px` }}\n >\n <span className=\"min-w-48 flex-1\">\n <strong className=\"block\">{unit.name}</strong>\n <span className=\"text-xs text-muted-foreground\">\n {unit.type}\n <span className=\"mx-2\">·</span>\n <span dir=\"ltr\">{unit.path}</span>\n </span>\n </span>\n <Badge variant=\"outline\">{unit.members} عضو</Badge>\n <span className=\"flex items-center gap-1\">\n <select\n aria-label={`نقل ${unit.name} إلى`}\n className={select}\n value={targets[unit.id] ?? String(unit.parentId ?? '')}\n onChange={(event) =>\n setTargets((current) => ({ ...current, [unit.id]: event.target.value }))\n }\n >\n <option value=\"\">— الجذر —</option>\n {candidates(unit).map((other) => (\n <option key={other.id} value={other.id}>\n {'· '.repeat(other.depth - 1)}\n {other.name}\n </option>\n ))}\n </select>\n <Button\n size=\"sm\"\n variant=\"outline\"\n aria-label={`نقل ${unit.name}`}\n onClick={() => move(unit)}\n >\n <CornerDownLeft size={14} />\n نقل\n </Button>\n </span>\n <span className=\"flex items-center gap-1\">\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إضافة وحدة فرعية تحت ${unit.name}`}\n onClick={() => setCreating({ parentId: unit.id, name: '', type: 'department' })}\n >\n <Plus size={15} />\n </Button>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إعادة تسمية ${unit.name}`}\n onClick={() => {\n setRenaming(unit)\n setRenameTo(unit.name)\n }}\n >\n <Pencil size={15} />\n </Button>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`حذف ${unit.name}`}\n onClick={() => setDeleting(unit)}\n >\n <Trash2 size={15} />\n </Button>\n </span>\n </li>\n ))}\n {units.length === 0 && (\n <li className=\"rounded-xl border border-dashed border-border p-10 text-center text-muted-foreground\">\n لا وحدات بعد؛ أضف الوحدة الجذر أولاً.\n </li>\n )}\n </ul>\n <Dialog open={creating !== null} onOpenChange={(open) => !open && setCreating(null)}>\n <DialogContent>\n <form onSubmit={submitCreate} className=\"space-y-5\">\n <DialogHeader>\n <DialogTitle>وحدة جديدة</DialogTitle>\n <DialogDescription>تُضاف تحت الوحدة الأم المختارة أو كجذر.</DialogDescription>\n </DialogHeader>\n <div className=\"space-y-1\">\n <Label htmlFor=\"unit-parent\">الوحدة الأم</Label>\n <select\n id=\"unit-parent\"\n className={`${select} w-full`}\n value={creating?.parentId ?? ''}\n onChange={(event) =>\n setCreating((draft) =>\n draft\n ? { ...draft, parentId: event.target.value ? Number(event.target.value) : null }\n : draft\n )\n }\n >\n <option value=\"\">— الجذر —</option>\n {units.map((unit) => (\n <option key={unit.id} value={unit.id}>\n {'· '.repeat(unit.depth - 1)}\n {unit.name}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"unit-name\">الاسم</Label>\n <Input\n id=\"unit-name\"\n value={creating?.name ?? ''}\n onChange={(event) =>\n setCreating((draft) => (draft ? { ...draft, name: event.target.value } : draft))\n }\n required\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"unit-type\">النوع</Label>\n <Input\n id=\"unit-type\"\n value={creating?.type ?? ''}\n onChange={(event) =>\n setCreating((draft) => (draft ? { ...draft, type: event.target.value } : draft))\n }\n required\n />\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setCreating(null)}>\n إلغاء\n </Button>\n <Button type=\"submit\">إضافة</Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n <Dialog open={renaming !== null} onOpenChange={(open) => !open && setRenaming(null)}>\n <DialogContent>\n <form onSubmit={submitRename} className=\"space-y-5\">\n <DialogHeader>\n <DialogTitle>إعادة تسمية {renaming?.name}</DialogTitle>\n </DialogHeader>\n <div className=\"space-y-1\">\n <Label htmlFor=\"rename-unit\">الاسم الجديد</Label>\n <Input\n id=\"rename-unit\"\n value={renameTo}\n onChange={(event) => setRenameTo(event.target.value)}\n required\n />\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setRenaming(null)}>\n إلغاء\n </Button>\n <Button type=\"submit\">حفظ</Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n <Dialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>حذف {deleting?.name}؟</DialogTitle>\n <DialogDescription>\n يُرفض الحذف إن كانت للوحدة وحدات فرعية أو أعضاء أو سجلات مقيدة بها.\n </DialogDescription>\n </DialogHeader>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setDeleting(null)}>\n إلغاء\n </Button>\n <Button\n type=\"button\"\n variant=\"destructive\"\n onClick={() =>\n deleting &&\n router.delete(`/admin/org-units/${deleting.id}`, {\n ...options,\n onSuccess: () => setDeleting(null),\n })\n }\n >\n حذف\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\nOrgUnitsIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/roles/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Plus, Trash2 } from 'lucide-react'\nimport type { RoleSummary } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { useConfirmAction } from '~/components/ui/confirm-action'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { roles: RoleSummary[] }\n\nexport default function RolesIndex({ roles }: Props) {\n const { confirm, confirmation } = useConfirmAction()\n const [name, setName] = useState('')\n const [level, setLevel] = useState('0')\n const create = (event: FormEvent) => {\n event.preventDefault()\n router.post(\n '/admin/roles',\n { name, permissionLevel: Number(level) },\n { preserveScroll: true, onSuccess: () => setName('') }\n )\n }\n return (\n <>\n <Head title=\"الأدوار\" />\n <AdminHeader\n title=\"الأدوار والصلاحيات\"\n description=\"كل دور يملك مصفوفة كيانات × إجراءات تُكتب مباشرة كقواعد.\"\n />\n <form\n onSubmit={create}\n className=\"mb-6 grid gap-3 rounded-xl border border-border bg-white p-5 md:grid-cols-[1fr_140px_auto]\"\n >\n <div className=\"space-y-1\">\n <Label htmlFor=\"role-name\">اسم الدور</Label>\n <Input\n id=\"role-name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n required\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"role-level\">مستوى الصلاحية</Label>\n <Input\n id=\"role-level\"\n type=\"number\"\n min={0}\n max={9}\n value={level}\n onChange={(event) => setLevel(event.target.value)}\n />\n </div>\n <Button type=\"submit\" className=\"self-end\">\n <Plus size={16} />\n إنشاء دور\n </Button>\n </form>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الدور</TableHead>\n <TableHead>المستوى</TableHead>\n <TableHead>القواعد</TableHead>\n <TableHead>المستخدمون</TableHead>\n <TableHead className=\"w-20\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {roles.map((role) => (\n <TableRow key={role.id}>\n <TableCell>\n <Link href={`/admin/roles/${role.id}`} className=\"font-semibold text-primary\">\n {role.name}\n </Link>\n </TableCell>\n <TableCell>{role.permissionLevel}</TableCell>\n <TableCell>{role.rules}</TableCell>\n <TableCell>{role.users}</TableCell>\n <TableCell>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`حذف الدور ${role.name}`}\n disabled={role.users > 0}\n onClick={() =>\n confirm({\n title: 'حذف الدور؟',\n description: `سيُحذف دور ${role.name} وقواعد صلاحياته. لا يمكن حذف دور مسند إلى مستخدمين.`,\n destructive: true,\n action: () => router.delete(`/admin/roles/${role.id}`),\n })\n }\n >\n <Trash2 size={15} />\n </Button>\n </TableCell>\n </TableRow>\n ))}\n {roles.length === 0 && (\n <TableRow>\n <TableCell colSpan={5} className=\"py-10 text-center text-muted-foreground\">\n لا أدوار بعد.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </section>\n {confirmation}\n </>\n )\n}\nRolesIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/roles/show.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { ArrowRight, Check, Pencil, Plus, Trash2, X } from 'lucide-react'\nimport type { MatrixField, MatrixSubject, RoleDetail, RoleMatrix, RoleRule } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Badge } from '~/components/ui/badge'\nimport { Checkbox } from '~/components/ui/checkbox'\nimport { useConfirmAction } from '~/components/ui/confirm-action'\nimport { ResourceSelect } from '~/components/ui/resource-field'\nimport { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { role: RoleDetail; matrix: RoleMatrix }\ntype Predicate = { field: string; operator: string; value: string }\ntype Scalar = string | number | boolean | null\n\nexport const ACTION_LABELS: Record<string, string> = {\n view: 'عرض',\n create: 'إنشاء',\n update: 'تعديل',\n delete: 'حذف',\n submit: 'اعتماد',\n cancel: 'إلغاء',\n amend: 'تعديل معتمد',\n manage: 'إدارة كاملة',\n invite: 'دعوة مستخدم',\n}\nconst OPERATORS: [string, string][] = [\n ['$eq', 'يساوي'],\n ['$ne', 'لا يساوي'],\n ['$in', 'ضمن قائمة'],\n ['$lt', 'أصغر من'],\n ['$gt', 'أكبر من'],\n ['$like', 'يشبه'],\n]\nconst NUMERIC = new Set(['integer', 'belongsTo'])\n\nfunction convert(field: MatrixField | undefined, operator: string, raw: string): Scalar | Scalar[] {\n const one = (text: string): Scalar => {\n const value = text.trim()\n if (value === 'null') return null\n if (field?.type === 'boolean') return value === 'true'\n if (field && NUMERIC.has(field.type)) return Number(value)\n return value\n }\n return operator === '$in' ? raw.split(',').map(one) : one(raw)\n}\nfunction toPredicates(conditions: RoleRule['conditions']): Predicate[] {\n return Object.entries(conditions ?? {}).flatMap(([field, condition]) =>\n condition !== null && typeof condition === 'object'\n ? Object.entries(condition).map(([operator, value]) => ({\n field,\n operator,\n value: Array.isArray(value) ? value.join(',') : String(value),\n }))\n : [{ field, operator: '$eq', value: String(condition) }]\n )\n}\nconst subjectLabel = (matrix: RoleMatrix, name: string) =>\n matrix.subjects.find((subject) => subject.name === name)?.label.ar ?? name\n\nfunction RuleEditor({\n role,\n rule,\n subject,\n onClose,\n}: {\n role: RoleDetail\n rule: RoleRule\n subject: MatrixSubject\n onClose: () => void\n}) {\n const { confirm, confirmation } = useConfirmAction()\n const [predicates, setPredicates] = useState<Predicate[]>(toPredicates(rule.conditions))\n const [fields, setFields] = useState<string[]>(rule.fields ?? [])\n const editable = subject.conditionFields.length > 0\n const update = (index: number, patch: Partial<Predicate>) =>\n setPredicates((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))\n const save = (event: FormEvent) => {\n event.preventDefault()\n const conditions: Record<string, Record<string, Scalar | Scalar[]>> = {}\n for (const predicate of predicates) {\n if (!predicate.field) continue\n conditions[predicate.field] = {\n ...(conditions[predicate.field] ?? {}),\n [predicate.operator]: convert(\n subject.conditionFields.find((field) => field.key === predicate.field),\n predicate.operator,\n predicate.value\n ),\n }\n }\n confirm({\n title: 'حفظ تغييرات الصلاحية؟',\n description:\n 'تُطبّق هذه التغييرات على جميع مستخدمي الدور فورًا. راجع الشروط والحقول قبل المتابعة.',\n action: () =>\n router.put(\n `/admin/roles/${role.id}/rules`,\n {\n subject: rule.subject,\n action: rule.action,\n inverted: rule.inverted,\n conditions,\n fields,\n },\n {\n preserveScroll: true,\n preserveState: true,\n onSuccess: (page) => {\n if (!(page as unknown as { flash?: { error?: string } }).flash?.error) onClose()\n },\n }\n ),\n })\n }\n return (\n <>\n <Dialog mode={editable ? 'edit' : 'view'} open onOpenChange={(open) => !open && onClose()}>\n <DialogContent className=\"max-w-2xl\">\n <form onSubmit={save} className=\"space-y-6\">\n <DialogHeader>\n <DialogTitle>\n {rule.inverted ? 'منع' : 'سماح'}: {subject.label.ar} / {ACTION_LABELS[rule.action]}\n </DialogTitle>\n <DialogDescription>\n {editable\n ? 'الشروط تُقيّد القاعدة بسجلات محددة، وقائمة الحقول تحصرها في حقول بعينها.'\n : 'الشروط والحقول تتطلب اختيار كيان محدد بدلاً من كل الكيانات.'}\n </DialogDescription>\n </DialogHeader>\n {editable && (\n <>\n <fieldset className=\"space-y-3\">\n <legend className=\"text-sm font-semibold\">الشروط</legend>\n {predicates.map((predicate, index) => (\n <div key={index} className=\"grid gap-2 md:grid-cols-[1fr_1fr_1fr_auto]\">\n <ResourceSelect\n aria-label={`حقل الشرط ${index + 1}`}\n value={predicate.field}\n onChange={(field) => update(index, { field })}\n options={subject.conditionFields.map((field) => ({\n value: field.key,\n label: field.label.ar,\n }))}\n placeholder=\"اختر حقلاً\"\n />\n <ResourceSelect\n aria-label={`عامل الشرط ${index + 1}`}\n value={predicate.operator}\n onChange={(operator) => update(index, { operator })}\n options={OPERATORS.map(([value, label]) => ({ value, label }))}\n />\n <Input\n aria-label={`قيمة الشرط ${index + 1}`}\n value={predicate.value}\n placeholder={predicate.operator === '$in' ? 'قيم مفصولة بفواصل' : 'القيمة'}\n onChange={(event) => update(index, { value: event.target.value })}\n />\n <Button\n type=\"button\"\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إزالة الشرط ${index + 1}`}\n onClick={() => setPredicates((rows) => rows.filter((_, i) => i !== index))}\n >\n <X size={15} />\n </Button>\n </div>\n ))}\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n onClick={() =>\n setPredicates((rows) => [...rows, { field: '', operator: '$eq', value: '' }])\n }\n >\n <Plus size={14} />\n إضافة شرط\n </Button>\n </fieldset>\n <fieldset className=\"space-y-3\">\n <legend className=\"text-sm font-semibold\">الحقول المسموح بها</legend>\n <p className=\"text-xs text-muted-foreground\">\n اتركها فارغة لتشمل القاعدة كل الحقول.\n </p>\n <div className=\"grid gap-2 sm:grid-cols-2 md:grid-cols-3\">\n {subject.fields.map((field) => (\n <label key={field.key} className=\"flex items-center gap-2 text-sm\">\n <Checkbox\n checked={fields.includes(field.key)}\n onCheckedChange={(checked) =>\n setFields((current) =>\n checked\n ? [...current, field.key]\n : current.filter((key) => key !== field.key)\n )\n }\n />\n {field.label.ar}\n </label>\n ))}\n </div>\n </fieldset>\n </>\n )}\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={onClose}>\n إلغاء\n </Button>\n {editable && <Button type=\"submit\">حفظ القاعدة</Button>}\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n {confirmation}\n </>\n )\n}\n\nexport default function RoleShow({ role, matrix }: Props) {\n const { confirm, confirmation } = useConfirmAction()\n const [busy, setBusy] = useState(false)\n const [name, setName] = useState(role.name)\n const [editing, setEditing] = useState<RoleRule | null>(null)\n const ruleFor = (subject: string, action: string, inverted: boolean) =>\n role.rules.find(\n (rule) => rule.subject === subject && rule.action === action && rule.inverted === inverted\n )\n const options = {\n preserveScroll: true,\n preserveState: true,\n onStart: () => setBusy(true),\n onFinish: () => setBusy(false),\n }\n const removeRule = (rule: RoleRule) =>\n confirm({\n title: 'حذف الصلاحية؟',\n description: `سيُحذف ${rule.inverted ? 'المنع' : 'السماح'}: ${subjectLabel(matrix, rule.subject)} / ${ACTION_LABELS[rule.action]}. يتأثر جميع مستخدمي الدور فورًا. لا يسمح النظام بإزالة آخر مدير أو صلاحيات إدارتك الحالية.`,\n destructive: true,\n label: 'تأكيد حذف الصلاحية',\n action: () => router.delete(`/admin/roles/${role.id}/rules/${rule.id}`, options),\n })\n const toggle = (subject: string, action: string, inverted: boolean) => {\n const existing = ruleFor(subject, action, inverted)\n if (existing) removeRule(existing)\n else\n confirm({\n title: inverted ? 'إضافة منع؟' : 'إضافة صلاحية؟',\n description: `${subjectLabel(matrix, subject)} / ${ACTION_LABELS[action]}. ${inverted ? 'المنع يتقدم على السماح وقد يمنع مستخدمي الدور من الوصول.' : 'سيحصل مستخدمو الدور على هذا الإجراء.'}`,\n destructive: inverted,\n action: () =>\n router.put(`/admin/roles/${role.id}/rules`, { subject, action, inverted }, options),\n })\n }\n const rename = (event: FormEvent) => {\n event.preventDefault()\n router.patch(`/admin/roles/${role.id}`, { name }, options)\n }\n const cell = (active: boolean, kind: 'allow' | 'deny') =>\n `grid size-7 place-items-center rounded-md border transition-colors ${\n active\n ? kind === 'allow'\n ? 'border-primary bg-primary text-white'\n : 'border-destructive bg-destructive text-white'\n : 'border-border text-muted-foreground hover:bg-background'\n }`\n const editingSubject = editing\n ? matrix.subjects.find((subject) => subject.name === editing.subject)\n : undefined\n return (\n <>\n <Head title={role.name} />\n <Link\n href=\"/admin/roles\"\n className=\"mb-6 inline-flex items-center gap-2 text-xs text-muted-foreground\"\n >\n <ArrowRight size={15} />\n العودة إلى الأدوار\n </Link>\n <AdminHeader title={role.name} description={`${role.users} مستخدم يحمل هذا الدور`} />\n <Alert className=\"mb-6\">\n <AlertTitle>حماية الوصول الإداري مفعّلة</AlertTitle>\n <AlertDescription>\n كل تغيير للصلاحيات يتطلب تأكيدًا. لا يمكن إزالة آخر مدير نشط أو سحب إدارة النظام من حسابك\n الحالي.\n </AlertDescription>\n </Alert>\n <form\n onSubmit={rename}\n className=\"mb-6 grid gap-3 rounded-xl border border-border bg-white p-5 md:grid-cols-[1fr_160px_auto]\"\n >\n <div className=\"space-y-1\">\n <Label htmlFor=\"role-name\">اسم الدور</Label>\n <Input\n id=\"role-name\"\n value={name}\n disabled={role.name === 'administrator' || busy}\n onChange={(event) => setName(event.target.value)}\n required\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"role-level\">مستوى الصلاحية</Label>\n <ResourceSelect\n id=\"role-level\"\n aria-label=\"مستوى الصلاحية\"\n value={String(role.permissionLevel)}\n disabled={busy}\n options={Array.from({ length: 10 }, (_, level) => ({\n value: String(level),\n label: String(level),\n }))}\n onChange={(value) =>\n confirm({\n title: 'تغيير مستوى الصلاحية؟',\n description: 'سيؤثر هذا التغيير على الحقول المتاحة لجميع مستخدمي الدور.',\n action: () =>\n router.patch(\n `/admin/roles/${role.id}`,\n { permissionLevel: Number(value) },\n options\n ),\n })\n }\n />\n </div>\n <Button\n type=\"submit\"\n variant=\"outline\"\n className=\"self-end\"\n disabled={role.name === 'administrator' || busy}\n >\n حفظ الاسم\n </Button>\n </form>\n <section className=\"mb-8 overflow-hidden rounded-xl border border-border bg-white\">\n <div className=\"flex items-center justify-between border-b border-border px-5 py-4\">\n <h2 className=\"font-semibold\">مصفوفة الصلاحيات</h2>\n <span className=\"flex items-center gap-4 text-xs text-muted-foreground\">\n <span className=\"flex items-center gap-1\">\n <span className=\"grid size-4 place-items-center rounded bg-primary text-white\">\n <Check size={10} />\n </span>\n سماح\n </span>\n <span className=\"flex items-center gap-1\">\n <span className=\"grid size-4 place-items-center rounded bg-destructive text-white\">\n <X size={10} />\n </span>\n منع (يتقدم على السماح)\n </span>\n </span>\n </div>\n <div className=\"overflow-x-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الكيان</TableHead>\n {matrix.actions.map((action) => (\n <TableHead key={action} className=\"text-center\">\n {ACTION_LABELS[action] ?? action}\n </TableHead>\n ))}\n </TableRow>\n </TableHeader>\n <TableBody>\n {matrix.subjects.map((subject) => (\n <TableRow key={subject.name}>\n <TableCell className=\"font-medium\">{subject.label.ar}</TableCell>\n {matrix.actions.map((action) => {\n if (!subject.actions.includes(action))\n return (\n <TableCell key={action} className=\"text-center text-muted-foreground\">\n —\n </TableCell>\n )\n const allow = ruleFor(subject.name, action, false)\n const deny = ruleFor(subject.name, action, true)\n return (\n <TableCell key={action} className=\"text-center\">\n <span className=\"inline-flex gap-1\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={busy}\n aria-pressed={Boolean(allow)}\n aria-label={`سماح: ${subject.label.ar} / ${ACTION_LABELS[action]}`}\n className={cell(Boolean(allow), 'allow')}\n onClick={() => toggle(subject.name, action, false)}\n >\n <Check size={14} />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={busy}\n aria-pressed={Boolean(deny)}\n aria-label={`منع: ${subject.label.ar} / ${ACTION_LABELS[action]}`}\n className={cell(Boolean(deny), 'deny')}\n onClick={() => toggle(subject.name, action, true)}\n >\n <X size={14} />\n </Button>\n </span>\n </TableCell>\n )\n })}\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </section>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <div className=\"border-b border-border px-5 py-4\">\n <h2 className=\"font-semibold\">القواعد وشروطها</h2>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n أضف شروطاً أو احصر القاعدة في حقول محددة؛ تُرفض الشروط غير المدعومة صراحةً.\n </p>\n </div>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الكيان</TableHead>\n <TableHead>الإجراء</TableHead>\n <TableHead>النوع</TableHead>\n <TableHead>الشروط</TableHead>\n <TableHead>الحقول</TableHead>\n <TableHead className=\"w-24\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {role.rules.map((rule) => (\n <TableRow key={rule.id}>\n <TableCell>{subjectLabel(matrix, rule.subject)}</TableCell>\n <TableCell>{ACTION_LABELS[rule.action] ?? rule.action}</TableCell>\n <TableCell>\n {rule.inverted ? <Badge variant=\"destructive\">منع</Badge> : <Badge>سماح</Badge>}\n </TableCell>\n <TableCell>\n <code className=\"text-xs\" dir=\"ltr\">\n {rule.conditions ? JSON.stringify(rule.conditions) : '—'}\n </code>\n </TableCell>\n <TableCell>\n <span className=\"flex flex-wrap gap-1\">\n {rule.fields?.map((field) => (\n <Badge key={field} variant=\"outline\">\n {field}\n </Badge>\n )) ?? <span className=\"text-muted-foreground\">كل الحقول</span>}\n </span>\n </TableCell>\n <TableCell>\n <span className=\"inline-flex gap-1\">\n <Button\n size=\"sm\"\n variant=\"outline\"\n disabled={busy}\n aria-label={`${rule.subject === 'all' ? 'تفاصيل' : 'تحرير'} قاعدة ${subjectLabel(matrix, rule.subject)} / ${ACTION_LABELS[rule.action]}`}\n onClick={() => setEditing(rule)}\n >\n <Pencil size={15} />\n {rule.subject === 'all' ? 'تفاصيل' : 'تحرير'}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n className=\"text-destructive\"\n disabled={busy}\n aria-label={`حذف قاعدة ${subjectLabel(matrix, rule.subject)} / ${ACTION_LABELS[rule.action]}`}\n onClick={() => removeRule(rule)}\n >\n <Trash2 size={15} />\n حذف\n </Button>\n </span>\n </TableCell>\n </TableRow>\n ))}\n {role.rules.length === 0 && (\n <TableRow>\n <TableCell colSpan={6} className=\"py-8 text-center text-muted-foreground\">\n لا قواعد بعد؛ فعّل خلية في المصفوفة أعلاه.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </section>\n {editing && editingSubject && (\n <RuleEditor\n role={role}\n rule={editing}\n subject={editingSubject}\n onClose={() => setEditing(null)}\n />\n )}\n {confirmation}\n </>\n )\n}\nRoleShow.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/sessions/index.tsx":"import { Head, usePage } from '@inertiajs/react'\nimport { Form } from '@adonisjs/inertia/react'\nimport type { ReactElement } from 'react'\nimport { CircleCheck, OctagonX, ShieldCheck } from 'lucide-react'\nimport type { ActiveSession } from '#services/sessions'\nimport { Button } from '~/components/ui/button'\nimport { Badge } from '~/components/ui/badge'\nimport { Alert, AlertDescription } from '~/components/ui/alert'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\nimport Workspace from '~/layouts/workspace'\nimport { describeAgent } from '~/pages/account/sessions'\nimport { useDateTimeFormatter } from '~/components/admin-nav'\n\ntype Props = { sessions: ActiveSession[]; currentSessionId: string }\n\nexport default function AdminSessions({ sessions, currentSessionId }: Props) {\n const formatWhen = useDateTimeFormatter()\n const { flash } = usePage()\n return (\n <>\n <Head title=\"الجلسات النشطة\" />\n <div className=\"mb-8\">\n <div className=\"mb-2 flex items-center gap-2 text-xs text-muted-foreground\">\n <ShieldCheck size={15} />\n <span>الإدارة</span>\n </div>\n <h1 className=\"text-3xl font-semibold tracking-tight\">الجلسات النشطة</h1>\n <p className=\"mt-3 text-sm text-muted-foreground\">\n كل الجلسات المفتوحة لجميع المستخدمين. إنهاء الجلسة يُخرج صاحبها فوراً.\n </p>\n </div>\n\n {typeof flash.success === 'string' && (\n <Alert className=\"mb-6\" data-flash-message={flash.success}>\n <CircleCheck />\n <AlertDescription>{flash.success}</AlertDescription>\n </Alert>\n )}\n {typeof flash.error === 'string' && (\n <Alert variant=\"destructive\" className=\"mb-6\" data-flash-message={flash.error}>\n <OctagonX />\n <AlertDescription>{flash.error}</AlertDescription>\n </Alert>\n )}\n\n <section\n className=\"overflow-hidden rounded-xl border border-border bg-white shadow-[0_2px_10px_#1c302804]\"\n aria-label=\"قائمة الجلسات النشطة\"\n >\n <div className=\"flex items-center gap-3 border-b border-border px-6 py-5\">\n <h2 className=\"text-sm font-semibold\">الجلسات</h2>\n <Badge variant=\"secondary\" className=\"font-normal tabular-nums\">\n {sessions.length} نشطة\n </Badge>\n </div>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>المستخدم</TableHead>\n <TableHead>الجهاز</TableHead>\n <TableHead>العنوان</TableHead>\n <TableHead>آخر نشاط</TableHead>\n <TableHead className=\"text-start\">إجراء</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {sessions.map((session) => {\n const current = session.id === currentSessionId\n return (\n <TableRow key={session.id}>\n <TableCell>\n <span className=\"block font-medium\">{session.fullName || '—'}</span>\n <span className=\"block text-xs text-muted-foreground\" dir=\"ltr\">\n {session.email}\n </span>\n </TableCell>\n <TableCell title={session.userAgent ?? undefined}>\n <span className=\"flex flex-wrap items-center gap-2\">\n {describeAgent(session.userAgent)}\n {current && <Badge>جلستك</Badge>}\n </span>\n </TableCell>\n <TableCell dir=\"ltr\" className=\"text-start tabular-nums\">\n {session.ip ?? '—'}\n </TableCell>\n <TableCell>{formatWhen(session.lastSeenAt)}</TableCell>\n <TableCell>\n <Form route=\"admin_sessions.destroy\" routeParams={{ id: session.id }}>\n {({ processing }) => (\n <Button\n type=\"submit\"\n size=\"sm\"\n variant=\"outline\"\n disabled={processing}\n aria-label={`إنهاء جلسة ${session.email}`}\n >\n إنهاء\n </Button>\n )}\n </Form>\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </Table>\n </section>\n </>\n )\n}\nAdminSessions.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/settings/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { Lock, Pencil, Plus, Trash2 } from 'lucide-react'\nimport type { SettingRow, SettingScope } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { UiSettings } from '~/components/ui-settings'\nimport { MailTest, type MailTestProps } from '~/components/mail-test'\nimport { useUiPreferences } from '~/components/ui/ui-preferences'\nimport { useConfirmAction } from '~/components/ui/confirm-action'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Badge } from '~/components/ui/badge'\nimport { Textarea } from '~/components/ui/textarea'\nimport { Tabs, TabsList, TabsTrigger } from '~/components/ui/tabs'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '~/components/ui/dialog'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { settings: SettingRow[]; scope: SettingScope; scopeId: string } & MailTestProps\ntype Draft = { id: number | null; key: string; value: string }\nconst SCOPES: [SettingScope, string][] = [\n ['system', 'النظام'],\n ['org_unit', 'وحدة تنظيمية'],\n ['user', 'مستخدم'],\n]\n\nfunction jsonError(text: string) {\n try {\n JSON.parse(text)\n return ''\n } catch {\n return 'القيمة ليست JSON صالحاً'\n }\n}\n\nexport default function SettingsIndex({\n settings,\n scope,\n scopeId,\n mailTest,\n mailRecipient,\n}: Props) {\n const preferences = useUiPreferences()\n const { confirm, confirmation } = useConfirmAction()\n const [target, setTarget] = useState(scopeId)\n const [draft, setDraft] = useState<Draft | null>(null)\n const options = { preserveScroll: true, preserveState: true }\n const visit = (nextScope: SettingScope, nextId: string) =>\n router.get('/admin/settings', { scope: nextScope, scopeId: nextId }, { preserveState: true })\n const error = draft ? jsonError(draft.value) : ''\n const save = (event: FormEvent) => {\n event.preventDefault()\n if (!draft || error) return\n router.put(\n '/admin/settings',\n { key: draft.key, scope, scopeId, value: draft.value },\n { ...options, onSuccess: () => setDraft(null) }\n )\n }\n return (\n <>\n <Head title=\"الإعدادات\" />\n <AdminHeader\n title=\"الإعدادات\"\n description=\"اضبط التواريخ وسلوك الواجهة، وراجع إعدادات النظام.\"\n >\n <Button\n disabled={scope !== 'system' && !/^\\d+$/.test(scopeId)}\n onClick={() => setDraft({ id: null, key: '', value: '' })}\n >\n <Plus size={16} />\n إضافة إعداد\n </Button>\n </AdminHeader>\n <UiSettings key={JSON.stringify(preferences)} initial={preferences} />\n <MailTest mailTest={mailTest} mailRecipient={mailRecipient} />\n <h2 className=\"mb-4 text-lg font-semibold\">إعدادات متقدمة</h2>\n <div className=\"mb-6 flex flex-wrap items-center gap-4\">\n <Tabs value={scope} onValueChange={(value) => visit(value as SettingScope, '')}>\n <TabsList>\n {SCOPES.map(([value, label]) => (\n <TabsTrigger key={value} value={value}>\n {label}\n </TabsTrigger>\n ))}\n </TabsList>\n </Tabs>\n {scope !== 'system' && (\n <form\n onSubmit={(event) => {\n event.preventDefault()\n visit(scope, target)\n }}\n className=\"flex items-end gap-2\"\n >\n <div className=\"space-y-1\">\n <Label htmlFor=\"scope-id\">\n {scope === 'org_unit' ? 'معرّف الوحدة' : 'معرّف المستخدم'}\n </Label>\n <Input\n id=\"scope-id\"\n type=\"number\"\n min={1}\n value={target}\n onChange={(event) => setTarget(event.target.value)}\n className=\"w-40 bg-white\"\n />\n </div>\n <Button type=\"submit\" variant=\"outline\">\n عرض\n </Button>\n </form>\n )}\n </div>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>المفتاح</TableHead>\n <TableHead>القيمة</TableHead>\n <TableHead>الحماية</TableHead>\n <TableHead className=\"w-24\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {settings.map((row) => (\n <TableRow key={row.id}>\n <TableCell dir=\"ltr\" className=\"font-medium\">\n {row.key}\n </TableCell>\n <TableCell>\n <code className=\"line-clamp-2 max-w-md text-xs\" dir=\"ltr\">\n {JSON.stringify(row.value)}\n </code>\n </TableCell>\n <TableCell>\n {row.readOnly ? (\n <Badge variant=\"secondary\">\n <Lock size={11} />\n للقراءة فقط\n </Badge>\n ) : (\n <span className=\"text-xs text-muted-foreground\">قابل للتحرير</span>\n )}\n </TableCell>\n <TableCell>\n <span className=\"inline-flex gap-1\">\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`تحرير ${row.key}`}\n disabled={row.readOnly}\n onClick={() =>\n setDraft({\n id: row.id,\n key: row.key,\n value: JSON.stringify(row.value, null, 2),\n })\n }\n >\n <Pencil size={15} />\n </Button>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`حذف ${row.key}`}\n disabled={row.readOnly}\n onClick={() =>\n confirm({\n title: 'حذف الإعداد؟',\n description: `سيُحذف ${row.key} وتعود القيم الافتراضية إن وُجدت.`,\n destructive: true,\n action: () => router.delete(`/admin/settings/${row.id}`, options),\n })\n }\n >\n <Trash2 size={15} />\n </Button>\n </span>\n </TableCell>\n </TableRow>\n ))}\n {settings.length === 0 && (\n <TableRow>\n <TableCell colSpan={4} className=\"py-10 text-center text-muted-foreground\">\n {scope !== 'system' && !/^\\d+$/.test(scopeId)\n ? 'أدخل معرّف النطاق لعرض إعداداته.'\n : 'لا إعدادات في هذا النطاق.'}\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n </section>\n <Dialog open={draft !== null} onOpenChange={(open) => !open && setDraft(null)}>\n <DialogContent>\n <form onSubmit={save} className=\"space-y-5\">\n <DialogHeader>\n <DialogTitle>{draft?.id ? `تحرير ${draft.key}` : 'إعداد جديد'}</DialogTitle>\n <DialogDescription>القيمة تُحفظ كما هي بصيغة JSON.</DialogDescription>\n </DialogHeader>\n <div className=\"space-y-1\">\n <Label htmlFor=\"setting-key\">المفتاح</Label>\n <Input\n id=\"setting-key\"\n dir=\"ltr\"\n value={draft?.key ?? ''}\n disabled={Boolean(draft?.id)}\n onChange={(event) =>\n setDraft((current) =>\n current ? { ...current, key: event.target.value } : current\n )\n }\n required\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"setting-value\">القيمة (JSON)</Label>\n <Textarea\n id=\"setting-value\"\n dir=\"ltr\"\n rows={6}\n value={draft?.value ?? ''}\n aria-invalid={Boolean(error)}\n onChange={(event) =>\n setDraft((current) =>\n current ? { ...current, value: event.target.value } : current\n )\n }\n required\n />\n {error && <p className=\"text-xs text-destructive\">{error}</p>}\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setDraft(null)}>\n إلغاء\n </Button>\n <Button type=\"submit\" disabled={Boolean(error) || !draft?.key}>\n حفظ\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n {confirmation}\n </>\n )\n}\nSettingsIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/setup/index.tsx":"import { Link } from '@adonisjs/inertia/react'\nimport { useState, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport type { setupSnapshot } from '#services/initial_setup'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { MailTest } from '~/components/mail-test'\nimport { Button } from '~/components/ui/button'\nimport { Card, CardHeader, CardTitle, CardDescription, CardContent } from '~/components/ui/card'\nimport { Badge } from '~/components/ui/badge'\nimport {\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogFooter,\n} from '~/components/ui/dialog'\n\ntype Props = Awaited<ReturnType<typeof setupSnapshot>>\nconst guides = {\n identity: {\n title: 'هوية الشركة',\n text: 'راجع الاسم والشعار والألوان والخطوط قبل الاعتماد. الهوية المثبتة مملوكة للمشروع؛ تُستكمل في docs/design-identity.md وcompany-identity.json وملفات brand. الاعتماد هنا يسجل موافقتك على الهوية المعروضة ولا يغيّر ملفاتها.',\n },\n mail: {\n title: 'إعداد البريد',\n text: 'اضبط SMTP_HOST وSMTP_PORT وSMTP_USERNAME وSMTP_PASSWORD وMAIL_FROM_NAME وMAIL_FROM_ADDRESS في بيئة التطبيق، ثم أعد تشغيل الخادم والعامل. استخدم عنوان مرسل موثقًا لدى مزودك واتبع تعليماته لتوثيق النطاق. لا تضع كلمات المرور في الإعدادات العامة أو مستودع المشروع. بعد ذلك أرسل تجربة وأكد استلامها.',\n },\n storage: {\n title: 'إعداد الملفات',\n text: 'التخزين المحلي متاح افتراضيًا ويحتاج مساحة دائمة. لاستخدام S3 اضبط DRIVE_DISK=s3 وAWS_ACCESS_KEY_ID وAWS_SECRET_ACCESS_KEY وAWS_REGION وS3_BUCKET وAWS_ENDPOINT عند الحاجة. أعد التشغيل ثم افحص التخزين. نقل الملفات القائمة يحتاج أمر adula:storage:migrate الموثق؛ تغيير الإعداد وحده لا ينقلها.',\n },\n runtime: {\n title: 'تشغيل الخدمات الخلفية',\n text: 'تحقق من اتصالات PostgreSQL وRedis في بيئة التطبيق. شغّل node ace adula:worker باستمرار، وعملية واحدة فقط من node ace scheduler:run تحت مدير عمليات أو خدمات النشر. نجاح الاتصال لا يثبت تنفيذ المهام؛ راقب نبضات التشغيل والطابور في صفحة تشغيل النظام.',\n },\n backup: {\n title: 'النسخ الاحتياطي والاستعادة',\n text: 'اضبط BACKUP_S3_ENDPOINT وBACKUP_S3_BUCKET وBACKUP_S3_REGION وBACKUP_S3_ACCESS_KEY_ID وBACKUP_S3_SECRET_ACCESS_KEY في بيئة التشغيل. شغّل خدمة النسخ المرفقة وحدد سياسة الاحتفاظ وفق احتياجك. افحص النسخة عبر node ace backup:verify، ثم نفّذ backup:restore-test على نسخة محمّلة من المخزن الخارجي. وجود الملفات لا يثبت استعادة سجل ومرفقه. لا تبدأ الاستعادة على قاعدة التطبيق الحالية.',\n },\n oauth: {\n title: 'الدخول الخارجي — اختياري',\n text: 'اضبط APP_URL ثم بيانات GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET أو GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET. سجل عنوان الرجوع APP_URL/oauth/google/callback أو APP_URL/oauth/github/callback لدى المزود. أعد التشغيل وجرّب الدخول في جلسة أخرى مع إبقاء جلسة المدير متاحة. لا يُعد المزود مختبرًا حتى ينجح الدخول الفعلي.',\n },\n}\n\nexport default function SetupIndex(props: Props) {\n const [guide, setGuide] = useState<keyof typeof guides | null>(null)\n const [busy, setBusy] = useState(false)\n const post = (path: string, data = {}) => {\n setBusy(true)\n router.post(path, data, {\n preserveScroll: true,\n onFinish: () => setBusy(false),\n onSuccess: () => setGuide(null),\n })\n }\n const checkLabel = (check: Props['storage']) =>\n !check\n ? 'لم يُختبر'\n : !check.fresh\n ? 'يلزم فحص حديث'\n : check.status === 'passed'\n ? 'نجح الفحص'\n : check.status === 'failed'\n ? 'فشل الفحص'\n : 'الفحص جارٍ أو انقطع؛ يمكن إعادته بعد خمس دقائق'\n const runtimeReady =\n props.health.heartbeats.worker.healthy && props.health.heartbeats.scheduler.healthy\n return (\n <>\n <Head title=\"الإعداد الأولي\" />\n <AdminHeader\n title=\"الإعداد الأولي\"\n description=\"أكمل الخطوات واختبر نتائجها. تُحفظ حالتك لتتابع لاحقًا؛ اكتمال هذه الصفحة لا يحل محل قبول بيئة الإنتاج.\"\n >\n <Button variant=\"outline\" disabled={busy} onClick={() => router.reload()}>\n تحديث الحالة\n </Button>\n </AdminHeader>\n <div className=\"mb-6 rounded-lg border bg-muted/30 p-4 text-sm\">\n {props.environment === 'development'\n ? 'بيئة تطوير: يمكنك تجربة التطبيق قبل ربط الخدمات الخارجية.'\n : 'بيئة إنتاج: عالج الخدمات غير المختبرة أو المتعطلة قبل الاعتماد على وظائفها.'}{' '}\n بيانات الاتصال والأسرار تُضبط في بيئة التطبيق، ولا تظهر هنا.\n </div>\n <div className=\"mb-6 grid gap-5 xl:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>١. هوية الشركة وتجربة الاستخدام</CardTitle>\n <CardDescription>{props.brand.company}</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Badge variant=\"secondary\">\n {props.identityConfirmed ? 'اعتمد المدير الهوية الحالية' : 'تحتاج مراجعة المدير'}\n </Badge>\n <p className=\"text-sm\">\n {props.brand.logo\n ? 'يوجد شعار ضمن الهوية المثبتة.'\n : 'لم يُقدّم شعار؛ راجع الهوية المؤقتة قبل اعتمادها.'}\n </p>\n <div className=\"flex gap-3\">\n <Button onClick={() => setGuide('identity')}>مراجعة الهوية</Button>\n <Button variant=\"outline\" asChild>\n <Link href=\"/admin/settings\">التقويم وتفضيلات الواجهة</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>٢. الإشعارات داخل التطبيق</CardTitle>\n <CardDescription>تحقق من وصول إشعار لحسابك وظهور حالة القراءة.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Badge variant=\"secondary\">\n {props.notification?.read\n ? 'تمت قراءة الإشعار التجريبي'\n : props.notification\n ? 'بانتظار قراءة الإشعار'\n : 'لم يُختبر'}\n </Badge>\n <p className=\"text-sm\">\n الإشعارات الداخلية متاحة. تفعيل البريد لا يحوّل كل إشعار تلقائيًا إلى رسالة بريدية.\n </p>\n <div className=\"flex gap-3\">\n <Button disabled={busy} onClick={() => post('/admin/setup/notification')}>\n إرسال إشعار تجريبي\n </Button>\n <Button variant=\"outline\" asChild>\n <Link href=\"/notifications\">فتح الإشعارات</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n </div>\n <MailTest mailTest={props.mailTest} mailRecipient={props.mailRecipient} returnTo=\"setup\" />\n <Button variant=\"link\" className=\"mb-6\" onClick={() => setGuide('mail')}>\n كيفية ضبط البريد بأمان\n </Button>\n <div className=\"grid gap-5 xl:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>٣. الملفات والمرفقات</CardTitle>\n <CardDescription>\n {props.storageDisk === 'local' ? 'التخزين المحلي' : 'تخزين S3'}\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <Badge variant=\"secondary\">{checkLabel(props.storage)}</Badge>\n <p className=\"text-sm\">\n الفحص يكتب ملفًا تجريبيًا صغيرًا، يقرأه ويتحقق من مطابقته، ثم يحذفه.\n </p>\n {props.storage && (\n <p className=\"text-xs\" dir=\"ltr\">\n {props.storage.checkedAt}\n </p>\n )}\n <div className=\"flex gap-3\">\n <Button disabled={busy} onClick={() => post('/admin/setup/check/storage')}>\n فحص التخزين\n </Button>\n <Button variant=\"outline\" onClick={() => setGuide('storage')}>\n إعداد التخزين\n </Button>\n </div>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>٤. الاتصال والخدمات الخلفية</CardTitle>\n <CardDescription>قاعدة البيانات وRedis والعامل والمجدول</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <p>الاتصال: {checkLabel(props.infrastructure)}</p>\n <p>العامل: {props.health.heartbeats.worker.healthy ? 'نشط' : 'لا توجد نبضة حديثة'}</p>\n <p>\n المجدول: {props.health.heartbeats.scheduler.healthy ? 'نشط' : 'لا توجد نبضة حديثة'}\n </p>\n <Badge variant=\"secondary\">\n {runtimeReady ? 'نبضات التشغيل حديثة' : 'تحتاج تشغيلًا أو فحصًا'}\n </Badge>\n <div className=\"flex flex-wrap gap-3\">\n <Button disabled={busy} onClick={() => post('/admin/setup/check/infrastructure')}>\n فحص الاتصال\n </Button>\n <Button variant=\"outline\" onClick={() => setGuide('runtime')}>\n تعليمات التشغيل\n </Button>\n <Button variant=\"link\" asChild>\n <Link href=\"/admin/jobs\">تشغيل النظام</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>٥. النسخ الاحتياطي</CardTitle>\n <CardDescription>نسخة خارجية مع اختبار استعادة مستقل</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <p>\n {props.backup.configured ? 'بيانات المخزن الخارجي موجودة' : 'المخزن الخارجي غير مهيأ'}\n </p>\n <p>\n {props.backup.inspection?.healthy &&\n Date.now() - Date.parse(props.backup.inspection.checkedAt) < 86_400_000\n ? 'فحص ملفات النسخة حديث وناجح'\n : 'لا يوجد فحص حديث ناجح لملفات النسخة'}\n </p>\n <p>\n اختبار الاستعادة:{' '}\n {props.backup.restore?.status === 'passed' && props.backup.restore.fileVerified\n ? 'نجحت استعادة سجل ومرفقه؛ يلزم التأكد من أن مصدر النسخة خارجي'\n : props.backup.restore?.status === 'failed'\n ? 'فشل آخر اختبار استعادة'\n : 'لم تُثبت استعادة سجل ومرفقه'}\n </p>\n <Button variant=\"outline\" onClick={() => setGuide('backup')}>\n إعداد النسخ والتحقق من الاستعادة\n </Button>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>٦. الدخول الخارجي</CardTitle>\n <CardDescription>اختياري؛ تسجيل الدخول المحلي يبقى متاحًا</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {props.oauth.map((provider) => (\n <p key={provider.provider}>\n <span dir=\"ltr\">{provider.provider}</span>:{' '}\n {provider.verifiedAt\n ? `نجح دخول فعلي بتاريخ ${provider.verifiedAt}`\n : provider.configured\n ? 'مهيأ ولم يُثبت الدخول'\n : 'غير مفعّل'}\n </p>\n ))}\n <Button variant=\"outline\" onClick={() => setGuide('oauth')}>\n إعداد الدخول الخارجي\n </Button>\n </CardContent>\n </Card>\n </div>\n <Dialog mode=\"view\" open={guide !== null} onOpenChange={(open) => !open && setGuide(null)}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{guide ? guides[guide].title : ''}</DialogTitle>\n <DialogDescription>{guide ? guides[guide].text : ''}</DialogDescription>\n </DialogHeader>\n <DialogFooter>\n {guide === 'identity' && (\n <Button\n disabled={busy}\n onClick={() => post('/admin/setup/identity', { confirmed: true })}\n >\n أعتمد الهوية الحالية\n </Button>\n )}\n <Button variant=\"outline\" onClick={() => setGuide(null)}>\n إغلاق\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </>\n )\n}\nSetupIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/users/index.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { Search } from 'lucide-react'\nimport type { UserPage } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader } from '~/components/admin-nav'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Badge } from '~/components/ui/badge'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { users: UserPage; search: string }\n\nexport default function UsersIndex({ users, search }: Props) {\n const { canInviteUsers } = usePage<{ canInviteUsers?: boolean }>().props\n const [term, setTerm] = useState(search)\n const submit = (event: FormEvent) => {\n event.preventDefault()\n router.get('/admin/users', term ? { search: term } : {}, { preserveState: true })\n }\n const next = users.nextCursor\n ? `/admin/users?cursor=${users.nextCursor}${search ? `&search=${encodeURIComponent(search)}` : ''}`\n : null\n return (\n <>\n <Head title=\"المستخدمون\" />\n <AdminHeader title=\"المستخدمون\" description=\"الأدوار والوحدات وحالة الحساب لكل مستخدم.\">\n {canInviteUsers && (\n <Button asChild>\n <Link href=\"/users/invite\">إضافة مستخدم</Link>\n </Button>\n )}\n </AdminHeader>\n <form onSubmit={submit} role=\"search\" className=\"mb-6 flex flex-wrap gap-2\">\n <Input\n aria-label=\"البحث عن مستخدم\"\n placeholder=\"البريد أو الاسم\"\n value={term}\n onChange={(event) => setTerm(event.target.value)}\n className=\"max-w-sm bg-white\"\n />\n <Button type=\"submit\" variant=\"outline\">\n <Search size={16} />\n بحث\n </Button>\n </form>\n <section className=\"overflow-hidden rounded-xl border border-border bg-white\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>المستخدم</TableHead>\n <TableHead>الأدوار</TableHead>\n <TableHead>الوحدات</TableHead>\n <TableHead>الحالة</TableHead>\n <TableHead className=\"w-24\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {users.data.map((user) => (\n <TableRow key={user.id}>\n <TableCell>\n <strong className=\"block\">{user.fullName || '—'}</strong>\n <span className=\"text-xs text-muted-foreground\" dir=\"ltr\">\n {user.email}\n </span>\n </TableCell>\n <TableCell>\n <span className=\"flex flex-wrap gap-1\">\n {user.roles.length === 0 && (\n <span className=\"text-xs text-muted-foreground\">بلا أدوار</span>\n )}\n {user.roles.map((role) => (\n <Badge key={role.id} variant=\"secondary\">\n {role.role}\n {role.orgUnit ? ` · ${role.orgUnit}` : ''}\n </Badge>\n ))}\n </span>\n </TableCell>\n <TableCell>\n <span className=\"flex flex-wrap gap-1\">\n {user.orgUnits.map((unit) => (\n <Badge key={unit.id} variant=\"outline\">\n {unit.name}\n </Badge>\n ))}\n </span>\n </TableCell>\n <TableCell>\n {user.disabledAt ? (\n <Badge variant=\"destructive\">معطّل</Badge>\n ) : (\n <Badge>نشط</Badge>\n )}\n </TableCell>\n <TableCell>\n <Button asChild size=\"sm\" variant=\"ghost\">\n <Link href={`/admin/users/${user.id}`}>تفاصيل</Link>\n </Button>\n </TableCell>\n </TableRow>\n ))}\n {users.data.length === 0 && (\n <TableRow>\n <TableCell colSpan={5} className=\"py-10 text-center text-muted-foreground\">\n لا يوجد مستخدمون مطابقون.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n {next && (\n <div className=\"border-t border-border p-4 text-center\">\n <Button asChild variant=\"outline\">\n <Link href={next}>الصفحة التالية</Link>\n </Button>\n </div>\n )}\n </section>\n </>\n )\n}\nUsersIndex.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/admin/users/show.tsx":"import { useState, type FormEvent, type ReactElement } from 'react'\nimport { Head, router } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport { ArrowRight, KeyRound, UserCog, UserMinus, UserCheck, X } from 'lucide-react'\nimport type { OrgUnitNode, RoleSummary, UserSummary } from '@adula/kit'\nimport Workspace from '~/layouts/workspace'\nimport { AdminHeader, useDateTimeFormatter } from '~/components/admin-nav'\nimport { useConfirmAction } from '~/components/ui/confirm-action'\nimport { Button } from '~/components/ui/button'\nimport { Badge } from '~/components/ui/badge'\nimport { Label } from '~/components/ui/label'\nimport { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/card'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/components/ui/table'\n\ntype Props = { user: UserSummary; roles: RoleSummary[]; orgUnits: OrgUnitNode[] }\nconst select = 'h-10 w-full rounded-md border border-input bg-white px-3 text-sm'\n\nexport default function UserShow({ user, roles, orgUnits }: Props) {\n const formatDateTime = useDateTimeFormatter()\n const { confirm, confirmation } = useConfirmAction()\n const [roleId, setRoleId] = useState(roles[0] ? String(roles[0].id) : '')\n const [roleUnit, setRoleUnit] = useState('')\n const [unitId, setUnitId] = useState('')\n const base = `/admin/users/${user.id}`\n const post = (url: string, data: Record<string, string | number | null> = {}) =>\n router.post(url, data, { preserveScroll: true })\n const assignRole = (event: FormEvent) => {\n event.preventDefault()\n confirm({\n title: 'إسناد الدور؟',\n description: 'ستتغير الصلاحيات المتاحة لهذا المستخدم فورًا.',\n action: () => post(`${base}/roles`, { roleId, orgUnitId: roleUnit || null }),\n })\n }\n const assignUnit = (event: FormEvent) => {\n event.preventDefault()\n if (unitId) post(`${base}/org-units`, { orgUnitId: unitId })\n }\n const available = orgUnits.filter((unit) => !user.orgUnits.some((own) => own.id === unit.id))\n return (\n <>\n <Head title={user.fullName || user.email} />\n <Link\n href=\"/admin/users\"\n className=\"mb-6 inline-flex items-center gap-2 text-xs text-muted-foreground\"\n >\n <ArrowRight size={15} />\n العودة إلى المستخدمين\n </Link>\n <AdminHeader title={user.fullName || user.email} description={user.email}>\n {user.disabledAt ? (\n <Button variant=\"outline\" onClick={() => post(`${base}/enable`)}>\n <UserCheck size={16} />\n تفعيل الحساب\n </Button>\n ) : (\n <Button\n variant=\"destructive\"\n onClick={() =>\n confirm({\n title: 'تعطيل الحساب؟',\n description: 'سيفقد المستخدم الوصول إلى النظام. لا يمكن تعطيل آخر مدير نشط.',\n destructive: true,\n action: () => post(`${base}/disable`),\n })\n }\n >\n <UserMinus size={16} />\n تعطيل الحساب\n </Button>\n )}\n <Button\n variant=\"outline\"\n onClick={() =>\n confirm({\n title: 'إنهاء جميع الجلسات؟',\n description: 'سيُطلب من هذا المستخدم تسجيل الدخول مجددًا.',\n destructive: true,\n action: () => post(`${base}/revoke-sessions`),\n })\n }\n >\n <KeyRound size={16} />\n إنهاء الجلسات\n </Button>\n <Button\n variant=\"outline\"\n disabled={Boolean(user.disabledAt)}\n onClick={() => post(`${base}/impersonate`)}\n >\n <UserCog size={16} />\n انتحال الحساب\n </Button>\n </AdminHeader>\n <div className=\"mb-6 flex flex-wrap items-center gap-3 text-sm\">\n {user.disabledAt ? (\n <Badge variant=\"destructive\">معطّل منذ {formatDateTime(user.disabledAt)}</Badge>\n ) : (\n <Badge>نشط</Badge>\n )}\n </div>\n <div className=\"grid gap-6 lg:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>الأدوار</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>الدور</TableHead>\n <TableHead>الوحدة</TableHead>\n <TableHead className=\"w-16\" />\n </TableRow>\n </TableHeader>\n <TableBody>\n {user.roles.map((assignment) => (\n <TableRow key={assignment.id}>\n <TableCell>{assignment.role}</TableCell>\n <TableCell className=\"text-muted-foreground\">\n {assignment.orgUnit ?? 'كل الجهة'}\n </TableCell>\n <TableCell>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إزالة الدور ${assignment.role}`}\n onClick={() =>\n confirm({\n title: 'إزالة الدور؟',\n description: `سيُسحب دور ${assignment.role} من هذا المستخدم. لا يمكن سحب الإدارة من حسابك الحالي أو إزالة آخر مدير نشط.`,\n destructive: true,\n action: () =>\n router.delete(`${base}/roles/${assignment.id}`, {\n preserveScroll: true,\n }),\n })\n }\n >\n <X size={15} />\n </Button>\n </TableCell>\n </TableRow>\n ))}\n {user.roles.length === 0 && (\n <TableRow>\n <TableCell colSpan={3} className=\"text-center text-muted-foreground\">\n لا أدوار مسندة.\n </TableCell>\n </TableRow>\n )}\n </TableBody>\n </Table>\n <form onSubmit={assignRole} className=\"grid gap-3 md:grid-cols-[1fr_1fr_auto]\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"assign-role\">الدور</Label>\n <select\n id=\"assign-role\"\n className={select}\n value={roleId}\n onChange={(event) => setRoleId(event.target.value)}\n required\n >\n {roles.map((role) => (\n <option key={role.id} value={role.id}>\n {role.name}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"assign-role-unit\">مقيّد بوحدة (اختياري)</Label>\n <select\n id=\"assign-role-unit\"\n className={select}\n value={roleUnit}\n onChange={(event) => setRoleUnit(event.target.value)}\n >\n <option value=\"\">كل الجهة</option>\n {orgUnits.map((unit) => (\n <option key={unit.id} value={unit.id}>\n {'· '.repeat(unit.depth - 1)}\n {unit.name}\n </option>\n ))}\n </select>\n </div>\n <Button type=\"submit\" className=\"self-end\" disabled={!roleId}>\n إسناد الدور\n </Button>\n </form>\n </CardContent>\n </Card>\n <Card>\n <CardHeader>\n <CardTitle>الوحدات التنظيمية</CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-5\">\n <ul className=\"space-y-2\">\n {user.orgUnits.map((unit) => (\n <li\n key={unit.id}\n className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2 text-sm\"\n >\n <span>\n {unit.name}\n <span className=\"ms-2 text-xs text-muted-foreground\" dir=\"ltr\">\n {unit.path}\n </span>\n </span>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n aria-label={`إزالة من ${unit.name}`}\n onClick={() =>\n router.delete(`${base}/org-units/${unit.id}`, { preserveScroll: true })\n }\n >\n <X size={15} />\n </Button>\n </li>\n ))}\n {user.orgUnits.length === 0 && (\n <li className=\"text-sm text-muted-foreground\">\n لا عضويات؛ الكيانات المقيدة بالنطاق مخفية عن هذا المستخدم.\n </li>\n )}\n </ul>\n <form onSubmit={assignUnit} className=\"grid gap-3 md:grid-cols-[1fr_auto]\">\n <div className=\"space-y-1\">\n <Label htmlFor=\"assign-unit\">الوحدة</Label>\n <select\n id=\"assign-unit\"\n className={select}\n value={unitId}\n onChange={(event) => setUnitId(event.target.value)}\n >\n <option value=\"\">اختر وحدة</option>\n {available.map((unit) => (\n <option key={unit.id} value={unit.id}>\n {'· '.repeat(unit.depth - 1)}\n {unit.name}\n </option>\n ))}\n </select>\n </div>\n <Button type=\"submit\" className=\"self-end\" disabled={!unitId}>\n إضافة إلى الوحدة\n </Button>\n </form>\n </CardContent>\n </Card>\n </div>\n {confirmation}\n </>\n )\n}\nUserShow.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/auth/forgot.tsx":"import { Head } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\n\nexport default function Forgot() {\n return (\n <>\n <Head title=\"استعادة كلمة المرور\" />\n <div dir=\"rtl\" className=\"mx-auto w-full max-w-md py-10\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">استعادة كلمة المرور</CardTitle>\n <CardDescription>\n أدخل بريدك الإلكتروني وسنرسل إليك رابطاً صالحاً لساعة واحدة لاختيار كلمة مرور\n جديدة.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <Form route=\"password_reset.send\" className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input\n type=\"email\"\n name=\"email\"\n id=\"email\"\n dir=\"ltr\"\n autoComplete=\"username\"\n required\n aria-invalid={errors.email ? true : undefined}\n />\n {errors.email && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.email}\n </p>\n )}\n </div>\n <Button type=\"submit\" className=\"w-full\" disabled={processing}>\n إرسال رابط إعادة التعيين\n </Button>\n </>\n )}\n </Form>\n <p className=\"text-center text-sm text-muted-foreground\">\n تذكرت كلمة المرور؟{' '}\n <Link route=\"session.create\" className=\"font-medium text-primary\">\n العودة إلى تسجيل الدخول\n </Link>\n </p>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\n","inertia/pages/auth/invitation.tsx":"import { Head, useForm } from '@inertiajs/react'\nimport { ResourceSurface } from '~/components/ui/resource-surface'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\n\nexport default function Invitation({ token, valid }: { token: string; valid: boolean }) {\n const form = useForm({ password: '', passwordConfirmation: '', form: '' })\n return (\n <>\n <Head title=\"قبول الدعوة\" />\n <ResourceSurface\n title=\"مرحبًا بك — أنشئ حسابك\"\n description=\"اختر كلمة مرور خاصة بك لقبول الدعوة. يعيّن المسؤول صلاحيات العمل لحسابك.\"\n backHref=\"/login\"\n mode=\"edit\"\n >\n {valid ? (\n <form\n className=\"space-y-5\"\n onSubmit={(event) => {\n event.preventDefault()\n form.post(`/invitations/${encodeURIComponent(token)}`)\n }}\n >\n {form.errors.form && (\n <p role=\"alert\" className=\"text-destructive\">\n {form.errors.form}\n </p>\n )}\n <div className=\"space-y-2\">\n <Label htmlFor=\"password\">كلمة المرور</Label>\n <Input\n id=\"password\"\n type=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n minLength={8}\n maxLength={32}\n value={form.data.password}\n onChange={(e) => form.setData('password', e.target.value)}\n aria-invalid={Boolean(form.errors.password)}\n />\n {form.errors.password && <p role=\"alert\">{form.errors.password}</p>}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"passwordConfirmation\">تأكيد كلمة المرور</Label>\n <Input\n id=\"passwordConfirmation\"\n type=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n value={form.data.passwordConfirmation}\n onChange={(e) => form.setData('passwordConfirmation', e.target.value)}\n aria-invalid={Boolean(form.errors.passwordConfirmation)}\n />\n {form.errors.passwordConfirmation && (\n <p role=\"alert\">{form.errors.passwordConfirmation}</p>\n )}\n </div>\n <Button type=\"submit\" disabled={form.processing}>\n {form.processing ? 'جارٍ إنشاء الحساب…' : 'إنشاء حسابي'}\n </Button>\n </form>\n ) : (\n <p role=\"alert\">\n الدعوة غير صالحة أو انتهت صلاحيتها أو استُخدمت. اطلب دعوة جديدة من المسؤول.\n </p>\n )}\n </ResourceSurface>\n </>\n )\n}\n","inertia/pages/auth/login.tsx":"import { Head } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\n\ntype Props = { socialProviders: { name: string; label: string }[] }\n\nexport default function Login({ socialProviders }: Props) {\n return (\n <>\n <Head title=\"تسجيل الدخول\" />\n <div dir=\"rtl\" className=\"mx-auto w-full max-w-md py-10\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">تسجيل الدخول</CardTitle>\n <CardDescription>أدخل بيانات حسابك للمتابعة إلى مساحة العمل</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <Form route=\"session.store\" className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input\n type=\"email\"\n name=\"email\"\n id=\"email\"\n dir=\"ltr\"\n autoComplete=\"username\"\n required\n aria-invalid={errors.email ? true : undefined}\n />\n {errors.email && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.email}\n </p>\n )}\n </div>\n\n <div className=\"space-y-2\">\n <div className=\"flex items-center justify-between\">\n <Label htmlFor=\"password\">كلمة المرور</Label>\n <Link\n route=\"password_reset.forgot\"\n className=\"text-xs text-muted-foreground hover:text-foreground\"\n >\n نسيت كلمة المرور؟\n </Link>\n </div>\n <Input\n type=\"password\"\n name=\"password\"\n id=\"password\"\n dir=\"ltr\"\n autoComplete=\"current-password\"\n required\n aria-invalid={errors.password ? true : undefined}\n />\n {errors.password && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.password}\n </p>\n )}\n </div>\n\n <Button type=\"submit\" className=\"w-full\" disabled={processing}>\n دخول\n </Button>\n </>\n )}\n </Form>\n\n {socialProviders.length > 0 && (\n <div className=\"space-y-3\" aria-label=\"الدخول عبر مزوّد خارجي\">\n <p className=\"text-center text-xs text-muted-foreground\">أو تابع عبر</p>\n {socialProviders.map((provider) => (\n <Button key={provider.name} asChild variant=\"outline\" className=\"w-full\">\n {/* A full navigation: the provider redirect must leave the Inertia app. */}\n <a href={`/oauth/${provider.name}/redirect`}>الدخول عبر {provider.label}</a>\n </Button>\n ))}\n </div>\n )}\n\n <p className=\"text-center text-sm text-muted-foreground\">\n ليس لديك حساب؟{' '}\n <Link route=\"new_account.create\" className=\"font-medium text-primary\">\n إنشاء حساب\n </Link>\n </p>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\n","inertia/pages/auth/reset.tsx":"import { Head } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { TriangleAlert } from 'lucide-react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\n\ntype Props = { token: string; valid: boolean }\n\nexport default function Reset({ token, valid }: Props) {\n return (\n <>\n <Head title=\"كلمة مرور جديدة\" />\n <div dir=\"rtl\" className=\"mx-auto w-full max-w-md py-10\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">كلمة مرور جديدة</CardTitle>\n <CardDescription>اختر كلمة مرور جديدة لحسابك. ستُنهى جلساتك السابقة كلها.</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n {valid ? (\n <Form route=\"password_reset.update\" routeParams={{ token }} className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"password\">كلمة المرور الجديدة</Label>\n <Input\n type=\"password\"\n name=\"password\"\n id=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.password ? true : undefined}\n />\n {errors.password && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.password}\n </p>\n )}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"passwordConfirmation\">تأكيد كلمة المرور</Label>\n <Input\n type=\"password\"\n name=\"passwordConfirmation\"\n id=\"passwordConfirmation\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.passwordConfirmation ? true : undefined}\n />\n {errors.passwordConfirmation && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.passwordConfirmation}\n </p>\n )}\n </div>\n <Button type=\"submit\" className=\"w-full\" disabled={processing}>\n حفظ كلمة المرور\n </Button>\n </>\n )}\n </Form>\n ) : (\n <Alert variant=\"destructive\">\n <TriangleAlert />\n <AlertTitle>الرابط غير صالح</AlertTitle>\n <AlertDescription>\n رابط إعادة التعيين غير صالح أو انتهت صلاحيته أو استُخدم من قبل. اطلب رابطاً\n جديداً.\n </AlertDescription>\n </Alert>\n )}\n <p className=\"text-center text-sm text-muted-foreground\">\n <Link route=\"password_reset.forgot\" className=\"font-medium text-primary\">\n طلب رابط جديد\n </Link>\n </p>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\n","inertia/pages/auth/signup.tsx":"import { Head } from '@inertiajs/react'\nimport { Form, Link } from '@adonisjs/inertia/react'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\n\nexport default function Signup() {\n return (\n <>\n <Head title=\"إنشاء حساب\" />\n <div dir=\"rtl\" className=\"mx-auto w-full max-w-md py-10\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">إنشاء حساب</CardTitle>\n <CardDescription>\n أدخل بياناتك. يمنحك مدير النظام صلاحيات العمل بعد إنشاء الحساب.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-6\">\n <Form route=\"new_account.store\" className=\"space-y-5\">\n {({ errors, processing }) => (\n <>\n <div className=\"space-y-2\">\n <Label htmlFor=\"fullName\">الاسم الكامل</Label>\n <Input\n type=\"text\"\n name=\"fullName\"\n id=\"fullName\"\n autoComplete=\"name\"\n aria-invalid={errors.fullName ? true : undefined}\n />\n {errors.fullName && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.fullName}\n </p>\n )}\n </div>\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input\n type=\"email\"\n name=\"email\"\n id=\"email\"\n dir=\"ltr\"\n autoComplete=\"email\"\n required\n aria-invalid={errors.email ? true : undefined}\n />\n {errors.email && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.email}\n </p>\n )}\n </div>\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"password\">كلمة المرور</Label>\n <Input\n type=\"password\"\n name=\"password\"\n id=\"password\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.password ? true : undefined}\n />\n {errors.password && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.password}\n </p>\n )}\n </div>\n\n <div className=\"space-y-2\">\n <Label htmlFor=\"passwordConfirmation\">تأكيد كلمة المرور</Label>\n <Input\n type=\"password\"\n name=\"passwordConfirmation\"\n id=\"passwordConfirmation\"\n dir=\"ltr\"\n autoComplete=\"new-password\"\n required\n aria-invalid={errors.passwordConfirmation ? true : undefined}\n />\n {errors.passwordConfirmation && (\n <p className=\"text-sm text-destructive\" role=\"alert\">\n {errors.passwordConfirmation}\n </p>\n )}\n </div>\n\n <Button type=\"submit\" className=\"w-full\" disabled={processing}>\n إنشاء الحساب\n </Button>\n </>\n )}\n </Form>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n لديك حساب بالفعل؟{' '}\n <Link route=\"session.create\" className=\"font-medium text-primary\">\n تسجيل الدخول\n </Link>\n </p>\n </CardContent>\n </Card>\n </div>\n </>\n )\n}\n","inertia/pages/errors/not_found.tsx":"export default function NotFound() {\n return (\n <>\n <h1>Page not found</h1>\n </>\n )\n}\n","inertia/pages/errors/server_error.tsx":"export default function ServerError() {\n return (\n <>\n <h1>Something went wrong</h1>\n </>\n )\n}\n","inertia/pages/home.tsx":"import { Head, usePage } from '@inertiajs/react'\nimport { Link } from '@adonisjs/inertia/react'\nimport type { ResourceNavigation } from '@adula/kit'\nimport { Button } from '~/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'\nimport Workspace from '~/layouts/workspace'\nimport Layout from '~/layouts/default'\n\nexport default function Home() {\n const { user, isAdmin, navigation } = usePage<{\n user?: { email: string }\n isAdmin: boolean\n navigation: ResourceNavigation\n }>().props\n return (\n <>\n <Head title=\"مساحة العمل · adula kit\" />\n <div className=\"mx-auto max-w-5xl space-y-6 px-6 py-12\" dir=\"rtl\">\n <Card>\n <CardHeader>\n <CardTitle>\n <h1 className=\"text-3xl\">مساحة العمل</h1>\n </CardTitle>\n <CardDescription>وحدات أعمالك وإعدادات حسابك في مكان واحد.</CardDescription>\n </CardHeader>\n <CardContent className=\"flex flex-wrap gap-3\">\n {user ? (\n <>\n <Button asChild>\n <Link href=\"/account/profile\">حسابي</Link>\n </Button>\n <Button asChild variant=\"outline\">\n <Link href=\"/notifications\">الإشعارات</Link>\n </Button>\n {isAdmin && (\n <Button asChild variant=\"outline\">\n <Link href=\"/admin/setup\">بدء الإعداد الأولي</Link>\n </Button>\n )}\n {isAdmin && (\n <Button asChild variant=\"outline\">\n <Link href=\"/admin/users\">إدارة النظام</Link>\n </Button>\n )}\n </>\n ) : (\n <Button asChild>\n <Link href=\"/login\">تسجيل الدخول</Link>\n </Button>\n )}\n </CardContent>\n </Card>\n {(navigation ?? []).length ? (\n <div className=\"grid gap-4 sm:grid-cols-2\">\n {navigation.map((entry) => (\n <Card key={entry.href}>\n <CardHeader>\n <CardTitle>{entry.label}</CardTitle>\n </CardHeader>\n <CardContent>\n <Button asChild variant=\"outline\">\n <Link href={entry.href}>فتح {entry.label}</Link>\n </Button>\n </CardContent>\n </Card>\n ))}\n </div>\n ) : (\n <p className=\"text-sm text-muted-foreground\">ستظهر هنا وحدات العمل المتاحة لحسابك.</p>\n )}\n </div>\n </>\n )\n}\n\nHome.layout = (props: { user?: unknown }) => (props.user ? Workspace : Layout)\n","inertia/pages/resources/index.tsx":"import { Head } from '@inertiajs/react'\nimport type { SerializedRecord } from '@adula/kit/types'\ntype Props = {\n label: string\n name: string\n result: { data: SerializedRecord[]; meta: { nextCursor: string | null; limit: number } }\n}\nexport default function ResourceIndex({ label, name, result }: Props) {\n return (\n <main dir=\"rtl\" style={{ maxWidth: 1000, margin: '60px auto', padding: 24 }}>\n <Head title={label} />\n <a href=\"/\">العودة للرئيسية</a>\n <h1>{label}</h1>\n <p>واجهة فحص النواة · واجهة إدارة الموارد الكاملة ضمن المرحلة الثانية.</p>\n {result.data.length ? (\n <table style={{ width: '100%', borderCollapse: 'collapse' }}>\n <thead>\n <tr>\n {Object.keys(result.data[0]).map((key) => (\n <th key={key} style={{ textAlign: 'right', padding: 12 }}>\n {key}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {result.data.map((row) => (\n <tr key={String(row.id)}>\n {Object.keys(result.data[0]).map((key) => (\n <td key={key} style={{ padding: 12, borderTop: '1px solid #ddd' }}>\n {String(row[key] ?? '—')}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n ) : (\n <p>لا توجد سجلات متاحة ضمن صلاحياتك.</p>\n )}\n {result.meta.nextCursor && (\n <a href={`/resources/${name}?cursor=${encodeURIComponent(result.meta.nextCursor)}`}>\n الصفحة التالية ←\n </a>\n )}\n </main>\n )\n}\n","inertia/pages/resources/page.tsx":"import type { ReactElement } from 'react'\nimport { ResourcePage, type ResourcePageProps } from '~/components/ui/resource-page'\nimport Workspace from '~/layouts/workspace'\n\n// Inertia's page discovery applies Omit to top-level props; the discriminated union stays under `view`.\nexport default function Page(props: ResourcePageProps) {\n return <ResourcePage {...props} />\n}\nPage.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/pages/users/invite.tsx":"import type { ReactElement } from 'react'\nimport { Head, useForm, usePage } from '@inertiajs/react'\nimport Workspace from '~/layouts/workspace'\nimport { ResourceSurface } from '~/components/ui/resource-surface'\nimport { Button } from '~/components/ui/button'\nimport { Input } from '~/components/ui/input'\nimport { Label } from '~/components/ui/label'\n\nexport default function Invite() {\n const page = usePage<{ isAdmin?: boolean }>()\n const form = useForm({ fullName: '', email: '', form: '' })\n return (\n <>\n <Head title=\"دعوة مستخدم\" />\n <ResourceSurface\n title=\"إضافة مستخدم بدعوة بريدية\"\n description=\"يختار المدعو كلمة مروره عبر رابط صالح لمدة 24 ساعة. يعيّن المدير الأدوار بعد قبول الدعوة.\"\n backHref={page.props.isAdmin ? '/admin/users' : '/'}\n mode=\"edit\"\n >\n <form\n className=\"space-y-5\"\n onSubmit={(event) => {\n event.preventDefault()\n form.post('/users/invite', { onSuccess: () => form.reset() })\n }}\n >\n {typeof page.flash.success === 'string' && (\n <p role=\"status\" data-flash-message={page.flash.success}>\n {page.flash.success}\n </p>\n )}\n {form.errors.form && (\n <p role=\"alert\" className=\"text-destructive\">\n {form.errors.form}\n </p>\n )}\n <div className=\"space-y-2\">\n <Label htmlFor=\"fullName\">الاسم الكامل</Label>\n <Input\n id=\"fullName\"\n required\n maxLength={120}\n value={form.data.fullName}\n onChange={(e) => form.setData('fullName', e.target.value)}\n aria-invalid={Boolean(form.errors.fullName)}\n />\n {form.errors.fullName && <p role=\"alert\">{form.errors.fullName}</p>}\n </div>\n <div className=\"space-y-2\">\n <Label htmlFor=\"email\">البريد الإلكتروني</Label>\n <Input\n id=\"email\"\n type=\"email\"\n dir=\"ltr\"\n required\n maxLength={254}\n value={form.data.email}\n onChange={(e) => form.setData('email', e.target.value)}\n aria-invalid={Boolean(form.errors.email)}\n />\n {form.errors.email && <p role=\"alert\">{form.errors.email}</p>}\n </div>\n <p className=\"text-sm text-muted-foreground\">\n لإعادة إرسال دعوة لم تُقبل، أدخل البريد نفسه بعد دقيقة. يصبح الرابط السابق غير صالح.\n </p>\n <Button type=\"submit\" disabled={form.processing}>\n {form.processing ? 'جارٍ الإرسال…' : 'إرسال الدعوة'}\n </Button>\n </form>\n </ResourceSurface>\n </>\n )\n}\nInvite.layout = (page: ReactElement) => <Workspace>{page}</Workspace>\n","inertia/ssr.tsx":"import { client } from '~/client'\nimport { type ReactElement } from 'react'\nimport Layout from '~/layouts/default'\nimport { type Data } from '@generated/data'\nimport ReactDOMServer from 'react-dom/server'\nimport { createInertiaApp, type ResolvedComponent } from '@inertiajs/react'\nimport { TuyauProvider } from '@adonisjs/inertia/react'\nimport { resolvePageComponent } from '@adonisjs/inertia/helpers'\n\nexport default function render(page: any) {\n return createInertiaApp({\n page,\n render: ReactDOMServer.renderToString,\n resolve: (name) => {\n return resolvePageComponent<ResolvedComponent>(\n `./pages/${name}.tsx`,\n import.meta.glob<ResolvedComponent>('./pages/**/*.tsx', { eager: true }),\n (resolvedPage: ReactElement<Data.SharedProps>) => <Layout children={resolvedPage} />\n )\n },\n setup: ({ App, props }) => {\n return (\n <TuyauProvider client={client}>\n <App {...props} />\n </TuyauProvider>\n )\n },\n })\n}\n","inertia/tsconfig.json":"{\n \"extends\": \"@adonisjs/tsconfig/tsconfig.client.json\",\n \"compilerOptions\": {\n \"module\": \"ESNext\",\n \"jsx\": \"react-jsx\",\n \"paths\": {\n \"~/*\": [\"./*\"],\n \"@generated/*\": [\"../.adonisjs/client/*\"]\n }\n },\n \"include\": [\n \"./**/*.ts\",\n \"./**/*.tsx\",\n \"../.adonisjs/client/**/*.ts\",\n \"../.adonisjs/server/**/*.ts\"\n ]\n}\n","inertia/types.ts":"import { type Data } from '@generated/data'\nimport { type PropsWithChildren } from 'react'\nimport { type JSONDataTypes } from '@adonisjs/core/types/transformers'\n\nexport type InertiaProps<T extends JSONDataTypes = {}> = PropsWithChildren<Data.SharedProps & T>\n\n/**\n * Bridges the server side types into the Inertia client. \"usePage().props\" is\n * typed from the Inertia middleware share method and \"usePage().flash\" from\n * its flash method.\n */\ndeclare module '@inertiajs/core' {\n interface InertiaConfig {\n sharedPageProps: Data.SharedProps\n flashDataType: Data.FlashMessages\n }\n}\n","providers/api_provider.ts":"import { HttpContext } from '@adonisjs/core/http'\nimport { BaseSerializer } from '@adonisjs/core/transformers'\nimport { type SimplePaginatorMetaKeys } from '@adonisjs/lucid/types/querybuilder'\n\n/**\n * Custom serializer for API responses that ensures consistent JSON structure\n * across all API endpoints. Wraps response data in a 'data' property and handles\n * pagination metadata for Lucid ORM query results.\n */\nclass ApiSerializer extends BaseSerializer<{\n Wrap: 'data'\n PaginationMetaData: SimplePaginatorMetaKeys\n}> {\n /**\n * Wraps all serialized data under this key in the response object.\n * Example: { data: [...] } instead of returning raw arrays/objects\n */\n wrap: 'data' = 'data'\n\n /**\n * Validates and defines pagination metadata structure for paginated responses.\n * Ensures that pagination info from Lucid queries is properly formatted.\n *\n * @throws Error if metadata doesn't match Lucid's pagination structure\n */\n definePaginationMetaData(metaData: unknown): SimplePaginatorMetaKeys {\n if (!this.isLucidPaginatorMetaData(metaData)) {\n throw new Error(\n 'Invalid pagination metadata. Expected metadata to contain Lucid pagination keys'\n )\n }\n return metaData\n }\n}\n\n/**\n * Single instance of ApiSerializer used across the application\n */\nconst serializer = new ApiSerializer()\nconst serialize = Object.assign(\n function (this: HttpContext, ...[data, resolver]: Parameters<ApiSerializer['serialize']>) {\n return serializer.serialize(data, resolver ?? this.containerResolver)\n },\n {\n withoutWrapping(\n this: HttpContext,\n ...[data, resolver]: Parameters<ApiSerializer['serializeWithoutWrapping']>\n ) {\n return serializer.serializeWithoutWrapping(data, resolver ?? this.containerResolver)\n },\n }\n) as ApiSerializer['serialize'] & { withoutWrapping: ApiSerializer['serializeWithoutWrapping'] }\n\n/**\n * Adds the serialize method to all HttpContext instances.\n * Usage in controllers: return ctx.serialize(data)\n * This ensures all API responses follow the same structure with data wrapping.\n */\nHttpContext.instanceProperty('serialize', serialize)\n\n/**\n * Module augmentation to add the serialize method to HttpContext.\n * This allows controllers to use ctx.serialize() for consistent API responses.\n */\ndeclare module '@adonisjs/core/http' {\n export interface HttpContext {\n serialize: typeof serialize\n }\n}\n","resources/views/inertia_layout.edge":"<!DOCTYPE html>\n<html lang=\"ar\" dir=\"rtl\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title data-inertia>\n adula kit\n </title>\n\n @viteReactRefresh()\n @vite(['inertia/app.tsx'])\n @inertiaHead()\n @stack('dumper')\n </head>\n\n <body>\n @inertia()\n </body>\n\n</html>\n","start/env.ts":"/*\n|--------------------------------------------------------------------------\n| Environment variables service\n|--------------------------------------------------------------------------\n|\n| The `Env.create` method creates an instance of the Env service. The\n| service validates the environment variables and also cast values\n| to JavaScript data types.\n|\n*/\n\nimport { Env } from '@adonisjs/core/env'\n\nconst env = await Env.create(new URL('../', import.meta.url), {\n // Node\n NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),\n PORT: Env.schema.number(),\n HOST: Env.schema.string({ format: 'host' }),\n LOG_LEVEL: Env.schema.string(),\n\n // App\n ADULA_NAMESPACE: Env.schema.string(),\n APP_KEY: Env.schema.secret(),\n APP_URL: Env.schema.string({ format: 'url', tld: false }),\n\n // Session\n SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),\n DB_HOST: Env.schema.string(),\n DB_PORT: Env.schema.number(),\n DB_USER: Env.schema.string(),\n DB_PASSWORD: Env.schema.string(),\n DB_DATABASE: Env.schema.string(),\n REDIS_HOST: Env.schema.string(),\n REDIS_PORT: Env.schema.number(),\n REDIS_PASSWORD: Env.schema.string.optional(),\n DRIVE_DISK: Env.schema.enum(['local', 's3'] as const),\n BACKUP_S3_ENDPOINT: Env.schema.string.optional(),\n BACKUP_S3_BUCKET: Env.schema.string.optional(),\n BACKUP_S3_PREFIX: Env.schema.string.optional(),\n BACKUP_S3_REGION: Env.schema.string.optional(),\n BACKUP_S3_ACCESS_KEY_ID: Env.schema.string.optional(),\n BACKUP_S3_SECRET_ACCESS_KEY: Env.schema.string.optional(),\n\n /*\n |----------------------------------------------------------\n | Variables for configuring the limiter package\n |----------------------------------------------------------\n */\n LIMITER_STORE: Env.schema.enum.optional(['redis', 'memory'] as const),\n\n /*\n |----------------------------------------------------------\n | Variables for configuring the mail package\n |----------------------------------------------------------\n */\n MAIL_MAILER: Env.schema.enum.optional(['smtp'] as const),\n MAIL_FROM_NAME: Env.schema.string.optional(),\n MAIL_FROM_ADDRESS: Env.schema.string.optional(),\n SMTP_HOST: Env.schema.string.optional(),\n SMTP_PORT: Env.schema.number.optional(),\n SMTP_SECURE: Env.schema.boolean.optional(),\n SMTP_REQUIRE_TLS: Env.schema.boolean.optional(),\n SMTP_USERNAME: Env.schema.string.optional(),\n SMTP_PASSWORD: Env.schema.string.optional(),\n\n /*\n |----------------------------------------------------------\n | Variables for configuring ally package (a provider is\n | offered only when both of its values are present)\n |----------------------------------------------------------\n */\n GITHUB_CLIENT_ID: Env.schema.string.optional(),\n GITHUB_CLIENT_SECRET: Env.schema.string.optional(),\n GOOGLE_CLIENT_ID: Env.schema.string.optional(),\n GOOGLE_CLIENT_SECRET: Env.schema.string.optional(),\n\n /*\n |----------------------------------------------------------\n | Variables for the s3 drive disk (DRIVE_DISK=s3)\n |----------------------------------------------------------\n */\n AWS_ACCESS_KEY_ID: Env.schema.string.optional(),\n AWS_SECRET_ACCESS_KEY: Env.schema.string.optional(),\n AWS_REGION: Env.schema.string.optional(),\n AWS_ENDPOINT: Env.schema.string.optional(),\n S3_BUCKET: Env.schema.string.optional(),\n})\n\nif (env.get('NODE_ENV') === 'production') {\n for (const key of [\n 'BACKUP_S3_ENDPOINT',\n 'BACKUP_S3_BUCKET',\n 'BACKUP_S3_REGION',\n 'BACKUP_S3_ACCESS_KEY_ID',\n 'BACKUP_S3_SECRET_ACCESS_KEY',\n ] as const) {\n if (!env.get(key)) throw new Error(`${key} is required in production`)\n }\n if (env.get('SESSION_DRIVER') !== 'database')\n throw new Error('Production requires revocable database sessions')\n if (env.get('DRIVE_DISK') === 's3')\n for (const key of [\n 'AWS_ACCESS_KEY_ID',\n 'AWS_SECRET_ACCESS_KEY',\n 'AWS_REGION',\n 'S3_BUCKET',\n ] as const)\n if (!env.get(key)) throw new Error(`${key} is required when DRIVE_DISK=s3`)\n}\nexport default env\n","start/kernel.ts":"/*\n|--------------------------------------------------------------------------\n| HTTP kernel file\n|--------------------------------------------------------------------------\n|\n| The HTTP kernel file is used to register the middleware with the server\n| or the router.\n|\n*/\n\nimport router from '@adonisjs/core/services/router'\nimport server from '@adonisjs/core/services/server'\n\n/**\n * The error handler is used to convert an exception\n * to a HTTP response.\n */\nserver.errorHandler(() => import('#exceptions/handler'))\n\n/**\n * The server middleware stack runs middleware on all the HTTP\n * requests, even if there is no route registered for\n * the request URL.\n */\nserver.use([\n () => import('#middleware/container_bindings_middleware'),\n () => import('@adonisjs/static/static_middleware'),\n () => import('@adonisjs/cors/cors_middleware'),\n () => import('@adonisjs/vite/vite_middleware'),\n () => import('#middleware/inertia_middleware'),\n])\n\n/**\n * The router middleware stack runs middleware on all the HTTP\n * requests with a registered route.\n */\nrouter.use([\n () => import('@adonisjs/core/bodyparser_middleware'),\n () => import('@adonisjs/session/session_middleware'),\n () => import('@adonisjs/shield/shield_middleware'),\n () => import('@adonisjs/auth/initialize_auth_middleware'),\n () => import('#middleware/silent_auth_middleware'),\n])\n\n/**\n * Named middleware collection must be explicitly assigned to\n * the routes or the routes group.\n */\nexport const middleware = router.named({\n guest: () => import('#middleware/guest_middleware'),\n auth: () => import('#middleware/auth_middleware'),\n mcp: () => import('#middleware/mcp_middleware'),\n admin: () => import('#middleware/admin_middleware'),\n})\n","start/limiter.ts":"/*\n|--------------------------------------------------------------------------\n| Define HTTP limiters\n|--------------------------------------------------------------------------\n|\n| The \"limiter.define\" method creates an HTTP middleware to apply rate\n| limits on a route or a group of routes. Authentication endpoints are\n| keyed by IP (login also by e-mail); resource and MCP endpoints by user.\n|\n*/\n\nimport limiter from '@adonisjs/limiter/services/main'\nimport type { HttpContext } from '@adonisjs/core/http'\n\nconst message = 'محاولات كثيرة في وقت قصير. انتظر دقيقة ثم حاول مجدداً.'\nconst withMessage = (error: { setMessage(value: string): unknown }) => {\n error.setMessage(message)\n}\nconst emailOf = (ctx: HttpContext) =>\n String(ctx.request.input('email', '') ?? '')\n .trim()\n .toLowerCase()\n\n/** Login: 5 attempts per minute for one e-mail from one address. */\nexport const loginThrottle = limiter.define('login', (ctx) =>\n limiter\n .allowRequests(5)\n .every('1 minute')\n .usingKey(`${ctx.request.ip()}:${emailOf(ctx)}`)\n .limitExceeded(withMessage)\n)\n\n/** Signup: 3 accounts per minute per address. */\nexport const signupThrottle = limiter.define('signup', (ctx) =>\n limiter.allowRequests(3).every('1 minute').usingKey(ctx.request.ip()).limitExceeded(withMessage)\n)\n\n/** Recovery (forgot + reset): 3 submissions per minute per address. */\nexport const passwordThrottle = limiter.define('password', (ctx) =>\n limiter.allowRequests(3).every('1 minute').usingKey(ctx.request.ip()).limitExceeded(withMessage)\n)\n\n/** OAuth redirects and callbacks: 10 per minute per address. */\nexport const oauthThrottle = limiter.define('oauth', (ctx) =>\n limiter.allowRequests(10).every('1 minute').usingKey(ctx.request.ip()).limitExceeded(withMessage)\n)\n\n/** Resource and MCP routes: 300 requests per minute per authenticated user. */\nexport const apiThrottle = limiter.define('api', (ctx) =>\n limiter\n .allowRequests(300)\n .every('1 minute')\n .usingKey(ctx.auth.user ? `user:${ctx.auth.user.id}` : `ip:${ctx.request.ip()}`)\n .limitExceeded(withMessage)\n)\n","start/listeners.ts":"import type { Listener } from '@adula/kit'\nexport const listeners: Listener[] = []\n","start/modules.ts":"import { ResourceRegistry, type Module } from '@adula/kit'\n// adula:imports\nexport const modules: Module[] = [\n /* adula:modules */\n]\nexport const registry = new ResourceRegistry().register(modules)\n","start/routes.ts":"/*\n|--------------------------------------------------------------------------\n| Routes file\n|--------------------------------------------------------------------------\n|\n| The routes file is used for defining the HTTP routes.\n|\n*/\n\nimport { middleware } from '#start/kernel'\nimport { controllers } from '#generated/controllers'\nimport router from '@adonisjs/core/services/router'\nimport db from '@adonisjs/lucid/services/db'\nimport { Settings } from '@adula/kit'\nimport redis from '@adonisjs/redis/services/main'\nimport {\n apiThrottle,\n loginThrottle,\n oauthThrottle,\n passwordThrottle,\n signupThrottle,\n} from '#start/limiter'\nconst ResourcesController = () => import('#controllers/resources_controller')\nconst AttachmentsController = () => import('#controllers/attachments_controller')\nconst SavedViewsController = () => import('#controllers/saved_views_controller')\nconst PasswordResetController = () => import('#controllers/password_reset_controller')\nconst UserInvitationsController = () => import('#controllers/user_invitations_controller')\nconst OauthController = () => import('#controllers/oauth_controller')\nconst ProfileController = () => import('#controllers/profile_controller')\nconst AccountSessionsController = () => import('#controllers/account_sessions_controller')\nconst AdminSessionsController = () => import('#controllers/admin_sessions_controller')\nconst AdminUsersController = () => import('#controllers/admin/users_controller')\nconst AdminRolesController = () => import('#controllers/admin/roles_controller')\nconst AdminOrgUnitsController = () => import('#controllers/admin/org_units_controller')\nconst AdminActivityController = () => import('#controllers/admin/activity_controller')\nconst AdminJobsController = () => import('#controllers/admin/jobs_controller')\nconst AdminSettingsController = () => import('#controllers/admin/settings_controller')\nconst SetupController = () => import('#controllers/admin/setup_controller')\nconst NotificationsController = () => import('#controllers/admin/notifications_controller')\n\nrouter.on('/').renderInertia('home', {}).as('home')\n\nrouter.mcp().use([middleware.auth(), apiThrottle, middleware.mcp()])\n\nrouter.get('/health', async ({ response }) => {\n try {\n await db.rawQuery('SELECT 1')\n await Promise.race([\n redis.ping(),\n new Promise((_, reject) => {\n const timer = setTimeout(() => reject(new Error('Redis unavailable')), 2000)\n timer.unref()\n }),\n ])\n const lastOffsite = await new Settings(db.connection().getWriteClient()).get<string>(\n 'backup.lastOffsite'\n )\n return {\n status:\n lastOffsite && Date.now() - Date.parse(lastOffsite) < 48 * 3600000 ? 'ok' : 'degraded',\n database: 'ok',\n redis: 'ok',\n backup: { lastOffsite: lastOffsite ?? null },\n }\n } catch {\n return response.serviceUnavailable({ status: 'unhealthy', dependencies: 'unavailable' })\n }\n})\n\nrouter\n .group(() => {\n router.get('/resources/:resource', [ResourcesController, 'index'])\n router.get('/resources/:resource/create', [ResourcesController, 'create'])\n router.get('/resources/:resource/options/:field', [ResourcesController, 'options'])\n router.get('/resources/:resource/:id/edit', [ResourcesController, 'edit'])\n router.get('/resources/:resource/:id', [ResourcesController, 'show'])\n router.post('/resources/:resource', [ResourcesController, 'store'])\n router.patch('/resources/:resource/:id', [ResourcesController, 'update'])\n router.delete('/resources/:resource/:id', [ResourcesController, 'destroy'])\n router.post('/resources/:resource/:id/submit', [ResourcesController, 'submit'])\n router.post('/resources/:resource/:id/cancel', [ResourcesController, 'cancel'])\n router.post('/resources/:resource/views', [SavedViewsController, 'store'])\n router.delete('/resources/:resource/views/:id', [SavedViewsController, 'destroy'])\n router.post('/attachments', [AttachmentsController, 'store'])\n router.get('/attachments/:id', [AttachmentsController, 'show'])\n })\n .use([middleware.auth(), apiThrottle])\n\nrouter\n .group(() => {\n router.get('signup', [controllers.NewAccount, 'create'])\n router.get('invitations/:token', [UserInvitationsController, 'show'])\n router.post('invitations/:token', [UserInvitationsController, 'accept']).use(passwordThrottle)\n router.post('signup', [controllers.NewAccount, 'store']).use(signupThrottle)\n\n router.get('login', [controllers.Session, 'create'])\n router.post('login', [controllers.Session, 'store']).use(loginThrottle)\n\n router.get('password/forgot', [PasswordResetController, 'forgot'])\n router.post('password/forgot', [PasswordResetController, 'send']).use(passwordThrottle)\n router.get('password/reset/:token', [PasswordResetController, 'reset'])\n router.post('password/reset/:token', [PasswordResetController, 'update']).use(passwordThrottle)\n\n router.get('oauth/:provider/redirect', [OauthController, 'redirect']).use(oauthThrottle)\n router.get('oauth/:provider/callback', [OauthController, 'callback']).use(oauthThrottle)\n })\n .use(middleware.guest())\n\nrouter\n .group(() => {\n router.post('logout', [controllers.Session, 'destroy'])\n router.get('users/invite', [UserInvitationsController, 'create'])\n router.post('users/invite', [UserInvitationsController, 'store']).use(apiThrottle)\n\n router.get('account/profile', [ProfileController, 'show'])\n router.patch('account/profile', [ProfileController, 'update'])\n router.post('account/password', [ProfileController, 'password'])\n router.get('account/sessions', [AccountSessionsController, 'index'])\n router.delete('account/sessions', [AccountSessionsController, 'purge'])\n router.delete('account/sessions/:id', [AccountSessionsController, 'destroy'])\n\n router.get('admin/sessions', [AdminSessionsController, 'index'])\n router.delete('admin/sessions/:id', [AdminSessionsController, 'destroy'])\n })\n .use(middleware.auth())\n\nrouter\n .group(() => {\n router.get('notifications', [NotificationsController, 'index'])\n router.post('notifications/read-all', [NotificationsController, 'readAll'])\n router.post('notifications/:id/read', [NotificationsController, 'read'])\n router.post('impersonation/stop', [AdminUsersController, 'stopImpersonation'])\n })\n .use(middleware.auth())\n\nrouter\n .group(() => {\n router.get('/', ({ response }) => response.redirect('/admin/users'))\n router.get('users', [AdminUsersController, 'index'])\n router.get('users/:id', [AdminUsersController, 'show'])\n router.post('users/:id/roles', [AdminUsersController, 'assignRole'])\n router.delete('users/:id/roles/:assignment', [AdminUsersController, 'removeRole'])\n router.post('users/:id/org-units', [AdminUsersController, 'assignOrgUnit'])\n router.delete('users/:id/org-units/:orgUnit', [AdminUsersController, 'removeOrgUnit'])\n router.post('users/:id/disable', [AdminUsersController, 'disable'])\n router.post('users/:id/enable', [AdminUsersController, 'enable'])\n router.post('users/:id/revoke-sessions', [AdminUsersController, 'revokeSessions'])\n router.post('users/:id/impersonate', [AdminUsersController, 'impersonate'])\n router.get('roles', [AdminRolesController, 'index'])\n router.post('roles', [AdminRolesController, 'store'])\n router.get('roles/:id', [AdminRolesController, 'show'])\n router.patch('roles/:id', [AdminRolesController, 'update'])\n router.delete('roles/:id', [AdminRolesController, 'destroy'])\n router.put('roles/:id/rules', [AdminRolesController, 'setRule'])\n router.delete('roles/:id/rules/:rule', [AdminRolesController, 'removeRule'])\n router.get('org-units', [AdminOrgUnitsController, 'index'])\n router.post('org-units', [AdminOrgUnitsController, 'store'])\n router.patch('org-units/:id', [AdminOrgUnitsController, 'update'])\n router.post('org-units/:id/move', [AdminOrgUnitsController, 'move'])\n router.delete('org-units/:id', [AdminOrgUnitsController, 'destroy'])\n router.get('activity', [AdminActivityController, 'index'])\n router.get('jobs', [AdminJobsController, 'index'])\n router.post('jobs/:id/retry', [AdminJobsController, 'retry'])\n router.get('settings', [AdminSettingsController, 'index'])\n router.get('setup', [SetupController, 'index'])\n router.post('setup/check/:service', [SetupController, 'check'])\n router.post('setup/identity', [SetupController, 'confirmIdentity'])\n router.post('setup/notification', [SetupController, 'notification'])\n router.post('settings/mail/test', [AdminSettingsController, 'testMail'])\n router.post('settings/mail/confirm', [AdminSettingsController, 'confirmMail'])\n router.put('settings', [AdminSettingsController, 'upsert'])\n router.delete('settings/:id', [AdminSettingsController, 'destroy'])\n })\n .prefix('admin')\n .use([middleware.auth(), middleware.admin()])\n","start/scheduler.ts":"import scheduler from 'adonisjs-scheduler/services/main'\nimport db from '@adonisjs/lucid/services/db'\nimport { Settings } from '@adula/kit'\n// Exactly one scheduler process is deployed. The worker owns outbox publication.\nscheduler.command('backup:verify').hourly().withoutOverlapping()\n// The monthly drill restores the latest snapshot and opens a record with its attachment.\nscheduler.command('backup:restore-test').monthly().withoutOverlapping()\nscheduler\n .call(async () => {\n await new Settings(db.connection().getWriteClient()).set(\n 'scheduler.heartbeat',\n new Date().toISOString()\n )\n })\n .everyThirtySeconds()\n .immediate()\n .withoutOverlapping()\n","start/validator.ts":"/*\n|--------------------------------------------------------------------------\n| Validator file\n|--------------------------------------------------------------------------\n|\n| The validator file is used for configuring global transforms for VineJS.\n| The transform below converts all VineJS date outputs from JavaScript\n| Date objects to Luxon DateTime instances, so that validated dates are\n| ready to use with Lucid models and other parts of the app that expect\n| Luxon DateTime.\n|\n*/\n\nimport { DateTime } from 'luxon'\nimport { VineDate } from '@vinejs/vine'\n\ndeclare module '@vinejs/vine/types' {\n interface VineGlobalTransforms {\n date: DateTime\n }\n}\n\nVineDate.transform((value) => DateTime.fromJSDate(value))\n","bin/console.ts":"const cleanupCodegen = process.argv[2] === \"codegen\"\n/*\n|--------------------------------------------------------------------------\n| Ace entry point\n|--------------------------------------------------------------------------\n|\n| The \"console.ts\" file is the entrypoint for booting the AdonisJS\n| command-line framework and executing commands.\n|\n| Commands do not boot the application, unless the currently running command\n| has \"options.startApp\" flag set to true.\n|\n*/\n\nawait import('reflect-metadata')\nconst { Ignitor, prettyPrintError } = await import('@adonisjs/core')\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n if (filePath.startsWith('./') || filePath.startsWith('../')) {\n return import(new URL(filePath, APP_ROOT).href)\n }\n return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n .tap((app) => {\n app.booting(async () => {\n await import('#start/env')\n })\n app.listen('SIGTERM', () => app.terminate())\n app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n })\n .ace()\n .handle(process.argv.splice(2))\n .catch((error) => {\n process.exitCode = 1\n prettyPrintError(error)\n })\n\n .finally(async () => {\n if (cleanupCodegen) {\n const { default: app } = await import('@adonisjs/core/services/app')\n if (app.container.hasBinding('cache.manager')) {\n const cache = await app.container.make('cache.manager')\n await cache.disconnectAll()\n }\n if (app.container.hasBinding('redis')) {\n const redis = await app.container.make('redis')\n await redis.quitAll()\n }\n }\n })\n","bin/server.ts":"/*\n|--------------------------------------------------------------------------\n| HTTP server entrypoint\n|--------------------------------------------------------------------------\n|\n| The \"server.ts\" file is the entrypoint for starting the AdonisJS HTTP\n| server. Either you can run this file directly or use the \"serve\"\n| command to run this file and monitor file changes\n|\n*/\n\nawait import('reflect-metadata')\nconst { Ignitor, prettyPrintError } = await import('@adonisjs/core')\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n if (filePath.startsWith('./') || filePath.startsWith('../')) {\n return import(new URL(filePath, APP_ROOT).href)\n }\n return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n .tap((app) => {\n app.booting(async () => {\n await import('#start/env')\n })\n app.listen('SIGTERM', () => app.terminate())\n app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n })\n .httpServer()\n .start()\n .catch((error) => {\n process.exitCode = 1\n prettyPrintError(error)\n })\n","bin/test.ts":"/*\n|--------------------------------------------------------------------------\n| Test runner entrypoint\n|--------------------------------------------------------------------------\n|\n| The \"test.ts\" file is the entrypoint for running tests using Japa.\n|\n| Either you can run this file directly or use the \"test\"\n| command to run this file and monitor file changes.\n|\n*/\n\nprocess.env.NODE_ENV = 'test'\n\nimport 'reflect-metadata'\nimport { Ignitor, prettyPrintError } from '@adonisjs/core'\nimport { configure, processCLIArgs, run } from '@japa/runner'\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n if (filePath.startsWith('./') || filePath.startsWith('../')) {\n return import(new URL(filePath, APP_ROOT).href)\n }\n return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n .tap((app) => {\n app.booting(async () => {\n await import('#start/env')\n })\n app.listen('SIGTERM', () => app.terminate())\n app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n })\n .testRunner()\n .configure(async (app) => {\n const { runnerHooks, ...config } = await import('../tests/bootstrap.js')\n\n processCLIArgs(process.argv.splice(2))\n configure({\n ...app.rcFile.tests,\n ...config,\n ...{\n setup: runnerHooks.setup,\n teardown: runnerHooks.teardown.concat([() => app.terminate()]),\n },\n })\n })\n .run(() => run())\n .catch((error) => {\n process.exitCode = 1\n prettyPrintError(error)\n })\n","ace.js":"/*\n|--------------------------------------------------------------------------\n| JavaScript entrypoint for running ace commands\n|--------------------------------------------------------------------------\n|\n| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD\n| PROCESS.\n|\n| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build\n|\n| Since, we cannot run TypeScript source code using \"node\" binary, we need\n| a JavaScript entrypoint to run ace commands.\n|\n| This file registers the \"ts-node/esm\" hook with the Node.js module system\n| and then imports the \"bin/console.ts\" file.\n|\n*/\n\n/**\n * Register hook to process TypeScript files using @poppinss/ts-exec\n */\nimport '@poppinss/ts-exec'\n\n/**\n * Import ace console entrypoint\n */\nawait import('./bin/console.js')\n","adonisrc.ts":"import { indexPages } from '@adonisjs/inertia'\nimport { indexEntities } from '@adonisjs/core'\nimport { defineConfig } from '@adonisjs/core/app'\nimport { generateRegistry } from '@tuyau/core/hooks'\n\nexport default defineConfig({\n /*\n |--------------------------------------------------------------------------\n | Experimental flags\n |--------------------------------------------------------------------------\n |\n | The following features will be enabled by default in the next major release\n | of AdonisJS. You can opt into them today to avoid any breaking changes\n | during upgrade.\n |\n */\n experimental: {},\n\n /*\n |--------------------------------------------------------------------------\n | Commands\n |--------------------------------------------------------------------------\n |\n | List of ace commands to register from packages. The application commands\n | will be scanned automatically from the \"./commands\" directory.\n |\n */\n commands: [\n () => import('@adonisjs/core/commands'),\n () => import('@adonisjs/lucid/commands'),\n () => import('@adonisjs/session/commands'),\n () => import('@adonisjs/inertia/commands'),\n () => import('@adula/kit/commands'),\n () => import('@adonisjs/cache/commands'),\n () => import('@nemoventures/adonis-jobs/commands'),\n () => import('adonisjs-scheduler/commands'),\n () => import('@adonisjs/mail/commands'),\n () => import('@jrmc/adonis-attachment/commands'),\n ],\n\n /*\n |--------------------------------------------------------------------------\n | Service providers\n |--------------------------------------------------------------------------\n |\n | List of service providers to import and register when booting the\n | application\n |\n */\n providers: [\n () => import('@adonisjs/core/providers/app_provider'),\n () => import('@adonisjs/core/providers/hash_provider'),\n {\n file: () => import('@adonisjs/core/providers/repl_provider'),\n environment: ['repl', 'test'],\n },\n () => import('@adonisjs/core/providers/vinejs_provider'),\n () => import('@adonisjs/core/providers/edge_provider'),\n () => import('@adonisjs/session/session_provider'),\n () => import('@adonisjs/vite/vite_provider'),\n () => import('@adonisjs/shield/shield_provider'),\n () => import('@adonisjs/static/static_provider'),\n () => import('@adonisjs/lucid/database_provider'),\n () => import('@adonisjs/cors/cors_provider'),\n () => import('@adonisjs/inertia/inertia_provider'),\n () => import('@adonisjs/auth/auth_provider'),\n () => import('#providers/api_provider'),\n () => import('@adula/kit/provider'),\n () => import('@adonisjs/redis/redis_provider'),\n () => import('@adonisjs/cache/cache_provider'),\n () => import('@nemoventures/adonis-jobs/queue_provider'),\n () => import('adonisjs-scheduler/scheduler_provider'),\n () => import('@jrmc/adonis-mcp/mcp_provider'),\n () => import('@adonisjs/limiter/limiter_provider'),\n () => import('@adonisjs/mail/mail_provider'),\n () => import('@jrmc/adonis-attachment/attachment_provider'),\n () => import('@adonisjs/ally/ally_provider'),\n () => import('@adonisjs/drive/drive_provider'),\n ],\n\n /*\n |--------------------------------------------------------------------------\n | Preloads\n |--------------------------------------------------------------------------\n |\n | List of modules to import before starting the application.\n |\n */\n preloads: [\n () => import('#start/routes'),\n () => import('#start/kernel'),\n () => import('#start/validator'),\n { file: () => import('#start/scheduler'), environment: ['console'] },\n ],\n\n /*\n |--------------------------------------------------------------------------\n | Tests\n |--------------------------------------------------------------------------\n |\n | List of test suites to organize tests by their type. Feel free to remove\n | and add additional suites.\n |\n */\n tests: {\n suites: [\n {\n files: ['tests/unit/**/*.spec.{ts,js}'],\n name: 'unit',\n timeout: 2000,\n },\n {\n files: ['tests/functional/**/*.spec.{ts,js}'],\n name: 'functional',\n timeout: 30000,\n },\n {\n files: ['tests/browser/**/*.spec.{ts,js}'],\n name: 'browser',\n timeout: 300000,\n },\n ],\n forceExit: false,\n },\n\n /*\n |--------------------------------------------------------------------------\n | Metafiles\n |--------------------------------------------------------------------------\n |\n | A collection of files you want to copy to the build folder when creating\n | the production build.\n |\n */\n metaFiles: [\n { pattern: 'company-identity.json', reloadServer: false },\n { pattern: 'docs/design-identity.md', reloadServer: false },\n { pattern: 'inertia/brand.ts', reloadServer: false },\n { pattern: 'inertia/css/brand.css', reloadServer: false },\n {\n pattern: 'resources/views/**/*.edge',\n reloadServer: false,\n },\n {\n pattern: 'public/**',\n reloadServer: false,\n },\n ],\n\n hooks: {\n init: [\n indexEntities({\n transformers: { enabled: true, withSharedProps: true },\n }),\n indexPages({ framework: 'react' }),\n generateRegistry(),\n ],\n buildStarting: [() => import('@adonisjs/vite/build_hook')],\n },\n})\n","components.json":"{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"new-york\",\n \"rsc\": false,\n \"tsx\": true,\n \"rtl\": true,\n \"tailwind\": {\n \"config\": \"\",\n \"css\": \"inertia/css/kit.css\",\n \"baseColor\": \"neutral\",\n \"cssVariables\": true\n },\n \"aliases\": {\n \"components\": \"~/components\",\n \"ui\": \"~/components/ui\",\n \"utils\": \"~/lib/utils\",\n \"lib\": \"~/lib\",\n \"hooks\": \"~/hooks\"\n },\n \"iconLibrary\": \"lucide\"\n}\n","tsconfig.json":"{\n \"extends\": \"@adonisjs/tsconfig/tsconfig.app.json\",\n \"exclude\": [\"node_modules\", \"build\", \"inertia\"],\n \"compilerOptions\": {\n \"rootDir\": \"./\",\n \"jsx\": \"react\",\n \"outDir\": \"./build\",\n \"paths\": {\n \"~/*\": [\"./inertia/*\"]\n }\n },\n \"references\": [\n {\n \"path\": \"./tsconfig.inertia.json\"\n }\n ]\n}\n","tsconfig.inertia.json":"/**\n * This file only exists to avoid the circular reference between the Inertia\n * codebase and the backend codebase, which takes place because of the\n * codegen. We have Inertia app referencing backend code and backend\n * code referencing Inertia pages for inferring props types.\n *\n * The main part here is \"composite: true\"\n */\n{\n \"extends\": \"./inertia/tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"./inertia\",\n \"composite\": true\n },\n \"include\": [\"./inertia/**/*.ts\", \"./inertia/**/*.tsx\"]\n}\n","vite.config.ts":"import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport adonisjs from '@adonisjs/vite/client'\nimport tailwindcss from '@tailwindcss/vite'\n\nexport default defineConfig({\n plugins: [\n react(),\n adonisjs({ entryPoints: ['inertia/app.tsx'], reload: ['resources/views/**/*.edge'] }),\n tailwindcss(),\n ],\n\n /**\n * Define aliases for importing modules from\n * your frontend code\n */\n resolve: {\n alias: {\n '~/': `${import.meta.dirname}/inertia/`,\n '@generated': `${import.meta.dirname}/.adonisjs/client/`,\n },\n },\n\n server: {\n watch: {\n ignored: ['**/storage/**', '**/tmp/**'],\n },\n },\n})\n","eslint.config.js":"import { configApp } from '@adonisjs/eslint-config'\nimport kit from '@adula/kit/eslint'\n\nexport default configApp({\n files: ['app/modules/**/*.ts', 'app/controllers/**/*.ts'],\n plugins: { adula: kit },\n rules: {\n 'adula/no-cross-module-controller': 'error',\n 'adula/no-direct-to-json': 'error',\n 'adula/no-kit-patching': 'error',\n },\n})\n","database/schema_rules.ts":"import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator'\n\nexport default {} satisfies SchemaRules\n","tests/helpers/resource_contract.ts":"import { test } from '@japa/runner'\nimport User from '#models/user'\nimport app from '@adonisjs/core/services/app'\nimport db from '@adonisjs/lucid/services/db'\nimport { kit } from '#services/kit'\nimport { randomUUID } from 'node:crypto'\nimport { columnName } from '@adula/kit'\nimport type { Field, RecordData, Resource, SerializedRecord } from '@adula/kit'\n\nexport type FixtureContext = { userId: number; orgUnitId: number; unique: string }\nexport type ContractFixture = {\n input: RecordData\n /** Expected public values after validation; relations have separate tests. */\n expected: SerializedRecord\n /** Normalized database values for writable fields excluded from the response. */\n stored?: RecordData\n /** All live child rows after creation, ordered by ID, using public field names. */\n inline?: Record<string, RecordData[]>\n /** A valid update for this same record, including required validator fields. */\n update: RecordData\n updated: SerializedRecord\n updatedStored?: RecordData\n updatedInline?: Record<string, RecordData[]>\n}\nexport type ResourceFixture = (\n context: FixtureContext\n) => Promise<ContractFixture> | ContractFixture\n\nfunction storedValue(field: Field, value: unknown) {\n if (!(value instanceof Date)) return value\n return field.type === 'date'\n ? [\n value.getFullYear(),\n String(value.getMonth() + 1).padStart(2, '0'),\n String(value.getDate()).padStart(2, '0'),\n ].join('-')\n : value.toISOString()\n}\n\n/** Fixtures belong to the application and exercise its real validators and HTTP routes. */\nexport function resourceContract(name: string, fixture: ResourceFixture) {\n test.group(`HTTP security contract: ${name}`, (group) => {\n let denied: User\n let writer: User\n let reader: User\n let outsider: User\n let orgUnitId: number\n let resource: Resource\n const base = `/resources/${name}`\n const fresh = () => fixture({ userId: writer.id, orgUnitId, unique: randomUUID() })\n const input = (values: RecordData) => ({ ...values, ...(resource.scoped ? { orgUnitId } : {}) })\n const version = (row: SerializedRecord) => (resource.version ? { version: row.version } : {})\n\n group.setup(async () => {\n const knex = db.connection().getWriteClient()\n const databaseInfo = await knex.raw('SELECT current_database() AS name')\n const database = databaseInfo.rows[0].name\n if (!app.inTest || !database.endsWith('_test'))\n throw new Error('Resource contracts require a dedicated *_test database')\n resource = kit().registry.get(name)\n const suffix = randomUUID()\n const users = []\n for (const label of ['denied', 'writer', 'reader', 'outside']) {\n users.push(\n await User.create({\n fullName: 'مستخدم الاختبار',\n email: `${label}-${suffix}@example.test`,\n password: 'a-long-test-password-123',\n })\n )\n }\n ;[denied, writer, reader, outsider] = users\n const [org, outside] = await knex('org_units')\n .insert([\n { name: 'نطاق الاختبار', type: 'root', path: `contract_${suffix.replaceAll('-', '_')}` },\n { name: 'نطاق آخر', type: 'root', path: `outside_${suffix.replaceAll('-', '_')}` },\n ])\n .returning('id')\n orgUnitId = org.id\n const [writeRole, readRole] = await knex('roles')\n .insert([\n { name: `writer-${suffix}`, permission_level: 1 },\n { name: `reader-${suffix}`, permission_level: 0 },\n ])\n .returning('id')\n await knex('role_rules').insert([\n { role_id: writeRole.id, subject: 'all', action: 'manage' },\n { role_id: readRole.id, subject: 'all', action: 'view' },\n ])\n await knex('user_roles').insert([\n { user_id: writer.id, role_id: writeRole.id },\n { user_id: outsider.id, role_id: writeRole.id },\n { user_id: reader.id, role_id: readRole.id },\n ])\n await knex('user_org_units').insert([\n { user_id: writer.id, org_unit_id: orgUnitId },\n { user_id: reader.id, org_unit_id: orgUnitId },\n { user_id: outsider.id, org_unit_id: outside.id },\n ])\n })\n\n for (const [method, suffix] of [\n ['get', ''],\n ['get', '/create'],\n ['get', '/999999/edit'],\n ['get', '/999999'],\n ['post', ''],\n ['patch', '/999999'],\n ['delete', '/999999'],\n ['post', '/999999/submit'],\n ['post', '/999999/cancel'],\n ] as const) {\n test(`${method.toUpperCase()} ${suffix || '/'} returns 403 without roles`, async ({\n client,\n }) => {\n const response = await client[method](`${base}${suffix}`)\n .loginAs(denied)\n .withCsrfToken()\n .header('Accept', 'application/json')\n response.assertStatus(403)\n })\n }\n test('resource route is protected from anonymous access', async ({ client }) => {\n const response = await client.get(base).header('Accept', 'application/json')\n response.assertStatus(401)\n })\n test('existing records enforce scope on reads and writes; central resources stay shared', async ({\n client,\n assert,\n }) => {\n const values = await fresh()\n const created = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input(values.input))\n created.assertStatus(201)\n const row = created.body().data\n const visible = await client\n .get(`${base}/${row.id}`)\n .loginAs(writer)\n .header('Accept', 'application/json')\n visible.assertStatus(200)\n assert.equal(visible.body().data.id, row.id)\n const outside = await client\n .get(`${base}/${row.id}`)\n .loginAs(outsider)\n .header('Accept', 'application/json')\n outside.assertStatus(resource.scoped ? 404 : 200)\n if (!resource.scoped) return\n const listing = await client.get(base).loginAs(outsider).header('Accept', 'application/json')\n listing.assertStatus(200)\n assert.notInclude(\n listing.body().data.map((entry: SerializedRecord) => entry.id),\n row.id\n )\n const edit = await client\n .get(`${base}/${row.id}/edit`)\n .loginAs(outsider)\n .header('Accept', 'application/json')\n edit.assertStatus(404)\n const changed = await client\n .patch(`${base}/${row.id}`)\n .loginAs(outsider)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json({ ...values.update, ...version(row) })\n changed.assertStatus(404)\n const deleted = await client\n .delete(`${base}/${row.id}`)\n .loginAs(outsider)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(version(row))\n deleted.assertStatus(404)\n const after = await client\n .get(`${base}/${row.id}`)\n .loginAs(writer)\n .header('Accept', 'application/json')\n after.assertStatus(200)\n assert.deepEqual(after.body().data, row)\n })\n test('fixture fields round-trip, private values stay hidden, updates and soft deletion persist', async ({\n client,\n assert,\n }) => {\n const values = await fresh()\n for (const key of resource.form)\n assert.property(values.input, key, `Missing ${name}.${key} fixture`)\n const created = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input(values.input))\n created.assertStatus(201)\n const row = created.body().data\n assert.isNumber(row.id)\n for (const [key, value] of Object.entries(values.expected))\n assert.deepEqual(row[key], value, key)\n const exposed = new Set([\n 'id',\n ...(resource.serialize ?? [...resource.list, ...resource.show]),\n ...(resource.scoped ? ['orgUnitId'] : []),\n ...(resource.version ? ['version'] : []),\n ...(resource.submittable ? ['docStatus'] : []),\n ])\n for (const key of Object.keys(row))\n assert.isTrue(exposed.has(key), `Unexpected serialized field ${key}`)\n const assertStored = async (\n submitted: RecordData,\n expected: SerializedRecord,\n stored: RecordData = {},\n inline: Record<string, RecordData[]> = {}\n ) => {\n const persistedRow = await db\n .connection()\n .getWriteClient()(name)\n .where('id', row.id)\n .first()\n const expectations = { ...expected, ...stored }\n for (const key of Object.keys(submitted)) {\n if (!resource.form.includes(key)) continue\n const field = resource.fields[key]\n if (field.type === 'hasMany') {\n assert.property(inline, key, `Missing expected ${name}.${key} child rows`)\n const child = kit().registry.get(field.resource)\n const foreignKey = child.fields[field.foreignKey].column ?? columnName(field.foreignKey)\n const rows = await db\n .connection()\n .getWriteClient()(child.name)\n .where(foreignKey, row.id)\n .whereNull('deleted_at')\n .orderBy('id')\n assert.lengthOf(rows, inline[key].length)\n for (const [index, entry] of inline[key].entries()) {\n for (const childKey of child.form.filter((entryKey) => entryKey !== field.foreignKey))\n assert.property(entry, childKey, `Missing expected ${field.resource}.${childKey}`)\n for (const [childKey, value] of Object.entries(entry)) {\n const childField = child.fields[childKey]\n assert.exists(childField, `Unknown expected child field ${childKey}`)\n assert.deepEqual(\n storedValue(childField, rows[index][childField.column ?? columnName(childKey)]),\n value\n )\n }\n }\n } else {\n assert.property(expectations, key, `Missing stored ${name}.${key} expectation`)\n if (exposed.has(key))\n assert.property(expected, key, `Missing public ${name}.${key} expectation`)\n assert.deepEqual(\n storedValue(field, persistedRow[field.column ?? columnName(key)]),\n expectations[key],\n key\n )\n }\n }\n }\n await assertStored(values.input, values.expected, values.stored, values.inline)\n const shown = await client\n .get(`${base}/${row.id}`)\n .loginAs(reader)\n .header('Accept', 'application/json')\n shown.assertStatus(200)\n for (const [key, field] of Object.entries(resource.fields)) {\n if (field.permissionLevel || resource.hidden?.includes(key))\n assert.notProperty(shown.body().data, key)\n }\n for (const key of ['createdBy', 'updatedBy', 'deletedAt', 'orgPath', 'searchVector']) {\n const invalid = await client\n .patch(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json({ ...values.update, ...version(row), [key]: writer.id })\n invalid.assertStatus(422)\n assert.equal(invalid.body().error.code, 'E_FIELD_NOT_WRITABLE')\n }\n const updated = await client\n .patch(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json({ ...values.update, ...version(row) })\n updated.assertStatus(200)\n for (const [key, value] of Object.entries(values.updated))\n assert.deepEqual(updated.body().data[key], value, key)\n await assertStored(values.update, values.updated, values.updatedStored, values.updatedInline)\n if (resource.version) {\n assert.equal(updated.body().data.version, Number(row.version) + 1)\n const stale = await client\n .patch(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json({ ...values.update, ...version(row) })\n stale.assertStatus(409)\n }\n const persisted = await client\n .get(`${base}/${row.id}`)\n .loginAs(writer)\n .header('Accept', 'application/json')\n persisted.assertStatus(200)\n assert.deepEqual(persisted.body().data, updated.body().data)\n const removed = await client\n .delete(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(version(updated.body().data))\n removed.assertStatus(200)\n const stored = await db.connection().getWriteClient()(name).where('id', row.id).first()\n assert.exists(stored.deleted_at)\n assert.equal(stored.created_by, writer.id)\n const missing = await client\n .get(`${base}/${row.id}`)\n .loginAs(writer)\n .header('Accept', 'application/json')\n missing.assertStatus(404)\n })\n test('attachment fields reject uploads owned by another user and never serve them', async ({\n client,\n assert,\n }) => {\n const keys = resource.form.filter((key) => resource.fields[key].type === 'attachment')\n if (!keys.length) return\n const values = await fresh()\n for (const key of keys) {\n const foreign = await client\n .post('/attachments')\n .loginAs(outsider)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .fields({ resource: name, field: key })\n .file('file', Buffer.from(`foreign ${randomUUID()}`), {\n filename: 'foreign.txt',\n contentType: 'text/plain',\n })\n foreign.assertStatus(201)\n const rejected = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input({ ...values.input, [key]: foreign.body().data.id }))\n rejected.assertStatus(422)\n assert.equal(rejected.body().error.code, 'E_ATTACHMENT')\n const download = await client\n .get(foreign.body().data.url)\n .loginAs(writer)\n .header('Accept', 'application/json')\n download.assertStatus(404)\n }\n })\n test('unique constraints reject duplicates and allow reuse after soft deletion', async ({\n client,\n assert,\n }) => {\n const keys = Object.entries(resource.fields).filter(([, field]) => field.unique)\n if (!keys.length) return\n const values = await fresh()\n const created = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input(values.input))\n created.assertStatus(201)\n const row = created.body().data\n const knex = db.connection().getWriteClient()\n const stored = await knex(name).where('id', row.id).first()\n const distinct = await fresh()\n const second = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input(distinct.input))\n second.assertStatus(201)\n // Read-only sequence values also require real PostgreSQL uniqueness assertions.\n for (const [key, field] of keys) {\n const column = field.column ?? columnName(key)\n assert.isNotNull(stored[column], `Missing unique fixture ${key}`)\n await assert.rejects(\n () =>\n knex(name)\n .where('id', second.body().data.id)\n .update({ [column]: stored[column] }),\n /duplicate key/\n )\n }\n for (const [key, field] of keys) {\n if (field.sequence || !resource.form.includes(key)) continue\n const other = await fresh()\n const rejected = await client\n .post(base)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(input({ ...other.input, [key]: values.input[key] }))\n rejected.assertStatus(409)\n assert.equal(rejected.body().error.code, 'E_DUPLICATE')\n }\n const removed = await client\n .delete(`${base}/${row.id}`)\n .loginAs(writer)\n .withCsrfToken()\n .header('Accept', 'application/json')\n .json(version(row))\n removed.assertStatus(200)\n for (const [key, field] of keys) {\n const column = field.column ?? columnName(key)\n assert.equal(\n await knex(name)\n .where('id', second.body().data.id)\n .update({ [column]: stored[column] }),\n 1\n )\n }\n })\n })\n}\n","tests/bootstrap.ts":"import { assert } from '@japa/assert'\nimport app from '@adonisjs/core/services/app'\nimport type { Config } from '@japa/runner/types'\nimport { pluginAdonisJS } from '@japa/plugin-adonisjs'\nimport { dbAssertions } from '@adonisjs/lucid/plugins/db'\nimport testUtils from '@adonisjs/core/services/test_utils'\nimport { browserClient } from '@japa/browser-client'\nimport { authBrowserClient } from '@adonisjs/auth/plugins/browser_client'\nimport { sessionBrowserClient } from '@adonisjs/session/plugins/browser_client'\nimport { apiClient } from '@japa/api-client'\nimport { authApiClient } from '@adonisjs/auth/plugins/api_client'\nimport { sessionApiClient } from '@adonisjs/session/plugins/api_client'\nimport { shieldApiClient } from '@adonisjs/shield/plugins/api_client'\nimport { inertiaApiClient } from '@adonisjs/inertia/plugins/api_client'\nimport env from '#start/env'\nimport cache from '@adonisjs/cache/services/main'\nimport queue from '@nemoventures/adonis-jobs/services/main'\nimport { createServer } from 'node:http'\nimport type { Socket } from 'node:net'\n\n/**\n * This file is imported by the \"bin/test.ts\" entrypoint file\n */\n\n/**\n * Configure Japa plugins in the plugins array.\n * Learn more - https://japa.dev/docs/runner-config#plugins-optional\n */\nexport const plugins: Config['plugins'] = [\n assert(),\n pluginAdonisJS(app),\n dbAssertions(app),\n browserClient({ runInSuites: ['browser'] }),\n sessionBrowserClient(app),\n authBrowserClient(app),\n apiClient(),\n sessionApiClient(app),\n authApiClient(app),\n shieldApiClient(),\n inertiaApiClient(app),\n]\n\n/**\n * Configure lifecycle function to run before and after all the\n * tests.\n *\n * The setup functions are executed before all the tests\n * The teardown functions are executed after all the tests\n */\nexport const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {\n setup: [\n async () => {\n if (!env.get('DB_DATABASE').endsWith('_test') || !app.inTest)\n throw new Error('HTTP tests require a dedicated *_test database')\n // Both stores use the explicit application-specific test prefix and Redis DB 15.\n await queue.clear(['events'])\n await cache.clear()\n await testUtils.db().migrate()\n\n },\n ],\n teardown: [],\n}\n\n/**\n * Configure suites by tapping into the test suite instance.\n * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks\n */\nexport const configureSuite: Config['configureSuite'] = (suite) => {\n if (['browser', 'functional', 'e2e'].includes(suite.name)) {\n return suite.setup(async () => {\n const sockets = new Set<Socket>()\n const stop = await testUtils.httpServer().start((handler) => {\n const server = createServer(handler)\n server.on('connection', (socket) => {\n sockets.add(socket)\n socket.once('close', () => sockets.delete(socket))\n })\n return server\n })\n return async () => {\n const closing = stop()\n // Tests are finished; close preview/HMR connections too, including upgraded sockets.\n for (const socket of sockets) socket.destroy()\n await closing\n }\n })\n }\n}\n","commands/adula_worker.ts":"import { BaseCommand } from '@adonisjs/core/ace'\n\nexport default class AdulaWorker extends BaseCommand {\n static commandName = 'adula:worker'\n static description = 'Run the queue consumer and transactional outbox publisher'\n static options = { startApp: true, staysAlive: true }\n async run() {\n const { publishEvents } = await import('#services/events')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { Settings } = await import('@adula/kit')\n const worker = await this.kernel.exec('queue:work', [])\n if (worker.error) throw worker.error\n let active: Promise<void> | undefined\n let stopped = false\n const tick = () => {\n if (active || stopped) return\n active = (async () => {\n try {\n await publishEvents()\n await new Settings(db.connection().getWriteClient()).set(\n 'worker.heartbeat',\n new Date().toISOString()\n )\n } catch (error) {\n this.logger.error(error instanceof Error ? error.message : String(error))\n }\n })().finally(() => {\n active = undefined\n })\n }\n const timer = setInterval(tick, 1000)\n this.app.terminating(async () => {\n stopped = true\n clearInterval(timer)\n await active\n })\n tick()\n }\n}\n","commands/adula_outbox.ts":"import { BaseCommand } from '@adonisjs/core/ace'\n\nexport default class AdulaOutbox extends BaseCommand {\n static commandName = 'adula:outbox'\n static description = 'Publish one locked batch of durable events to the queue'\n static options = { startApp: true }\n async run() {\n const { publishEvents } = await import('#services/events')\n this.logger.info(`Published ${await publishEvents()} events`)\n }\n}\n","commands/adula_runtime_health.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\n\nexport default class AdulaRuntimeHealth extends BaseCommand {\n static commandName = 'adula:runtime:health'\n static description = 'Check the worker or single scheduler heartbeat'\n static options = { startApp: true }\n @flags.string({ default: 'worker' }) declare service: string\n async run() {\n if (!['worker', 'scheduler'].includes(this.service)) throw new Error('Unknown service')\n const { Settings } = await import('@adula/kit')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const last = await new Settings(db.connection().getWriteClient()).get<string>(\n `${this.service}.heartbeat`\n )\n const age = Date.now() - Date.parse(last ?? '')\n const healthy = Number.isFinite(age) && age >= 0 && age < 60000\n this.logger.log(`${this.service}: ${healthy ? 'healthy' : 'unhealthy'}`)\n if (!healthy) this.exitCode = 1\n }\n}\n","commands/adula_restore_reconcile.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\n\nexport default class AdulaRestoreReconcile extends BaseCommand {\n static commandName = 'adula:restore:reconcile'\n static description =\n 'After a restore, rebuild the event queue and invalidate cached authorization'\n static options = { startApp: true }\n @flags.boolean() declare force: boolean\n async run() {\n if (!this.force)\n throw new Error(\n 'Stop web, worker and scheduler first; then pass --force after restoring the database'\n )\n const { default: queue } = await import('@nemoventures/adonis-jobs/services/main')\n const { default: cache } = await import('@adonisjs/cache/services/main')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n // This queue contains only deliveries whose authoritative records live in outbox.\n await queue.clear(['events'])\n await db\n .connection()\n .getWriteClient()\n .transaction(async (trx) => {\n await trx('outbox').update({ published_at: null })\n await trx('authorization_revision').where('id', 1).increment('version', 1)\n })\n await cache.clear()\n this.logger.success(\n 'Durable events will replay with listener deduplication; authorization cache cleared'\n )\n }\n}\n","commands/backup_verify.ts":"import { BaseCommand } from '@adonisjs/core/ace'\nimport { execFile } from 'node:child_process'\nimport { promisify } from 'node:util'\n\nexport default class BackupVerify extends BaseCommand {\n static commandName = 'backup:verify'\n static description = 'Verify recent database, uploads and checksum objects in offsite storage'\n static options = { startApp: true }\n async run() {\n const { verifyBackup } = await import('@adula/kit')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { default: env } = await import('#start/env')\n const prefix = env.get('BACKUP_S3_PREFIX') ?? 'adula'\n const status = await verifyBackup(\n db.connection().getWriteClient(),\n async () => {\n const endpoint = env.get('BACKUP_S3_ENDPOINT')\n const bucket = env.get('BACKUP_S3_BUCKET')\n if (!endpoint || !bucket) throw new Error('Offsite storage is not configured')\n const { stdout } = await promisify(execFile)(\n 'aws',\n [\n '--endpoint-url',\n endpoint,\n 's3api',\n 'list-objects-v2',\n '--bucket',\n bucket,\n '--prefix',\n prefix + '/',\n '--output',\n 'json',\n ],\n {\n timeout: 60000,\n maxBuffer: 16 * 1024 * 1024,\n windowsHide: true,\n env: {\n ...process.env,\n AWS_ACCESS_KEY_ID: env.get('BACKUP_S3_ACCESS_KEY_ID'),\n AWS_SECRET_ACCESS_KEY: env.get('BACKUP_S3_SECRET_ACCESS_KEY'),\n AWS_DEFAULT_REGION: env.get('BACKUP_S3_REGION'),\n AWS_PAGER: '',\n },\n }\n )\n const result = JSON.parse(stdout) as {\n Contents?: { Key: string; Size: number; LastModified: string }[]\n }\n return (result.Contents ?? []).map((item) => ({\n key: item.Key,\n size: item.Size,\n modifiedAt: item.LastModified,\n }))\n },\n Date.now(),\n prefix\n )\n const { Settings } = await import('@adula/kit')\n const { backupFingerprint } = await import('#services/initial_setup')\n await new Settings(db.connection().getWriteClient()).set('setup.backup_check', {\n fingerprint: backupFingerprint(),\n at: status.checkedAt,\n })\n this.logger.log(JSON.stringify(status))\n if (!status.healthy) this.exitCode = 1\n }\n}\n","commands/backup_create.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport { resolve, join, basename } from 'node:path'\nimport { createReadStream } from 'node:fs'\nimport { stat, writeFile } from 'node:fs/promises'\nimport { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'\n\nexport default class BackupCreate extends BaseCommand {\n static commandName = 'backup:create'\n static description =\n 'Create a consistent database and all-disk attachment snapshot; publish COMPLETE last'\n static options = { startApp: true }\n\n @flags.string({ required: true, description: 'New snapshot directory (must not exist)' })\n declare snapshot: string\n @flags.string({\n description: 'Offsite key prefix (defaults to adula/<snapshot directory name>/)',\n })\n declare prefix: string\n\n async run() {\n const { default: env } = await import('#start/env')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { Settings } = await import('@adula/kit')\n const { createSnapshot } = await import('#services/backup_snapshot')\n const { publishSnapshot } = await import('#services/backup_publish')\n const directory = resolve(this.snapshot)\n const bucket = env.get('BACKUP_S3_BUCKET')\n const prefix =\n this.prefix ?? `${env.get('BACKUP_S3_PREFIX') ?? 'adula'}/${basename(directory)}/`\n if (!/^adula\\/[a-zA-Z0-9/_-]+\\/$/.test(prefix) || prefix.includes('//'))\n throw new Error('Invalid offsite prefix')\n const client = bucket\n ? new S3Client({\n region: env.get('BACKUP_S3_REGION'),\n endpoint: env.get('BACKUP_S3_ENDPOINT'),\n credentials: {\n accessKeyId: env.get('BACKUP_S3_ACCESS_KEY_ID')!,\n secretAccessKey: env.get('BACKUP_S3_SECRET_ACCESS_KEY')!,\n },\n })\n : null\n try {\n if (client) {\n try {\n await client.send(new HeadObjectCommand({ Bucket: bucket, Key: prefix + 'COMPLETE' }))\n throw new Error('Refusing to overwrite a complete offsite snapshot')\n } catch (error) {\n if (\n (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode !== 404\n )\n throw error\n }\n }\n await createSnapshot(directory)\n if (client) {\n await publishSnapshot(directory, {\n exists: async () => {\n try {\n await client.send(new HeadObjectCommand({ Bucket: bucket, Key: prefix + 'COMPLETE' }))\n return true\n } catch (error) {\n if (\n (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode ===\n 404\n )\n return false\n throw error\n }\n },\n put: async (name, path) => {\n const info = path ? await stat(path) : null\n await client.send(\n new PutObjectCommand({\n Bucket: bucket,\n Key: prefix + name,\n Body: path ? createReadStream(path) : '',\n ContentLength: info?.size ?? 0,\n })\n )\n },\n read: async (name) => {\n const response = await client.send(\n new GetObjectCommand({ Bucket: bucket, Key: prefix + name })\n )\n if (!response.Body) throw new Error('Missing offsite body: ' + name)\n return response.Body as AsyncIterable<Uint8Array>\n },\n })\n await new Settings(db.connection().getWriteClient()).set(\n 'backup.lastOffsite',\n new Date().toISOString()\n )\n }\n await writeFile(join(directory, 'COMPLETE'), '')\n this.logger.success(`Backup complete: ${directory}`)\n } finally {\n client?.destroy()\n }\n }\n}\n","commands/backup_verify_snapshot.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport { mkdtemp, rm } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\n\nexport default class BackupVerifySnapshot extends BaseCommand {\n static commandName = 'backup:verify-snapshot'\n static description = 'Validate snapshot checksums, manifest and archive before database recovery'\n static options = { startApp: true }\n @flags.string({ required: true })\n declare snapshot: string\n\n async run() {\n const { verifySnapshot, extractSnapshot, verifyAttachments } =\n await import('#services/backup_snapshot')\n const { default: env } = await import('#start/env')\n const directory = resolve(this.snapshot)\n const files = await verifySnapshot(directory)\n if (!files && env.get('DRIVE_DISK') !== 'local')\n throw new Error('Legacy snapshot does not contain S3 attachments')\n const extracted = await mkdtemp(join(tmpdir(), 'adula-verify-'))\n try {\n await extractSnapshot(directory, extracted)\n if (files) await verifyAttachments(files, extracted, files)\n this.logger.success('Snapshot integrity verified')\n } finally {\n await rm(extracted, { recursive: true, force: true })\n }\n }\n}\n","commands/backup_restore_files.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport { mkdtemp, rm } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\n\nexport default class BackupRestoreFiles extends BaseCommand {\n static commandName = 'backup:restore-files'\n static description =\n 'Restore verified attachment bytes to their original disks after database recovery'\n static options = { startApp: true }\n\n @flags.string({ required: true })\n declare snapshot: string\n @flags.boolean({\n description: 'Write original attachment keys; stop application traffic before recovery',\n })\n declare apply: boolean\n\n async run() {\n if (!this.apply)\n throw new Error('Use --apply after restoring the database with application traffic stopped')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { verifySnapshot, extractSnapshot, verifyAttachments, restoreAttachments } =\n await import('#services/backup_snapshot')\n const snapshot = resolve(this.snapshot)\n const files = await verifySnapshot(snapshot)\n if (!files) throw new Error('Legacy snapshots must use the local-files restore script')\n const extracted = await mkdtemp(join(tmpdir(), 'adula-recovery-'))\n try {\n await extractSnapshot(snapshot, extracted)\n await verifyAttachments(\n files,\n extracted,\n await db.connection().getWriteClient()('attachments').select('*')\n )\n await restoreAttachments(files, extracted, false)\n this.logger.success(\n `Restored and verified ${files.length} attachments on their original disks`\n )\n } finally {\n await rm(extracted, { recursive: true, force: true })\n }\n }\n}\n","commands/backup_restore_test.ts":"import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport { execFile } from 'node:child_process'\nimport { randomUUID } from 'node:crypto'\nimport { mkdtemp, readdir, rm, stat } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\nimport { promisify } from 'node:util'\n\nconst run = promisify(execFile)\nconst SNAPSHOT = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}Z$/\n\ntype Report = {\n startedAt: string\n finishedAt?: string\n status: 'passed' | 'failed'\n snapshot?: string\n database?: string\n tables?: Record<string, number>\n verifiedFiles?: number\n attachment?: {\n id: number\n resource: string\n recordId: number\n field: string\n path: string\n size: number\n fileVerified: boolean\n } | null\n error?: string\n}\n\nasync function latestSnapshot(directory: string) {\n const entries = await readdir(directory, { withFileTypes: true })\n const candidates: string[] = []\n for (const entry of entries) {\n if (!entry.isDirectory() || !SNAPSHOT.test(entry.name)) continue\n try {\n await stat(join(directory, entry.name, 'COMPLETE'))\n candidates.push(entry.name)\n } catch {}\n }\n if (!candidates.length) throw new Error(`No complete snapshot found under ${directory}`)\n return join(directory, candidates.sort().at(-1)!)\n}\n\nexport default class BackupRestoreTest extends BaseCommand {\n static commandName = 'backup:restore-test'\n static description =\n 'Restore the latest snapshot into a temporary database and verify a record together with its attachment file'\n static options = { startApp: true }\n @flags.string({ description: 'Directory that holds dated snapshots (default: /backups)' })\n declare dir: string\n @flags.string({ description: 'Explicit snapshot directory instead of the latest one' })\n declare snapshot: string\n @flags.boolean({ description: 'Pass even when no record carries an attachment' })\n declare allowEmpty: boolean\n\n async run() {\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const { default: env } = await import('#start/env')\n const { Settings } = await import('@adula/kit')\n const { isRelativeDiskPath } = await import('@adula/kit')\n const { registry } = await import('#start/modules')\n const { verifySnapshot, extractSnapshot, verifyAttachments, restoreAttachments } =\n await import('#services/backup_snapshot')\n const live = db.connection().getWriteClient()\n const settings = new Settings(live)\n const startedAt = new Date()\n const report: Report = { startedAt: startedAt.toISOString(), status: 'failed' }\n const stamp = startedAt\n .toISOString()\n .replace(/[-:]/g, '')\n .replace(/\\.\\d+Z$/, '')\n .toLowerCase()\n const database = `${env.get('DB_DATABASE').slice(0, 20)}_restore_${stamp}_${randomUUID().slice(0, 8)}_test`\n const pgEnv = {\n ...process.env,\n PGHOST: env.get('DB_HOST'),\n PGPORT: String(env.get('DB_PORT')),\n PGUSER: env.get('DB_USER'),\n PGPASSWORD: env.get('DB_PASSWORD'),\n }\n let extracted: string | undefined\n let connected = false\n let created = false\n try {\n const snapshot = this.snapshot\n ? resolve(this.snapshot)\n : await latestSnapshot(resolve(this.dir ?? '/backups'))\n report.snapshot = snapshot\n const files = await verifySnapshot(snapshot)\n await live.raw('CREATE DATABASE ??', [database])\n created = true\n report.database = database\n await run(\n 'pg_restore',\n [\n '--no-owner',\n '--no-privileges',\n '--exit-on-error',\n `--dbname=${database}`,\n join(snapshot, 'database.dump'),\n ],\n { env: pgEnv, windowsHide: true, timeout: 600000, maxBuffer: 16 * 1024 * 1024 }\n )\n extracted = await mkdtemp(join(tmpdir(), 'adula-restore-'))\n await extractSnapshot(snapshot, extracted)\n db.manager.add(database, {\n client: 'pg',\n connection: {\n host: env.get('DB_HOST'),\n port: env.get('DB_PORT'),\n user: env.get('DB_USER'),\n password: env.get('DB_PASSWORD'),\n database,\n },\n pool: { min: 0, max: 2 },\n })\n connected = true\n const restored = db.connection(database).getWriteClient()\n const tables: Record<string, number> = {}\n for (const resource of registry.all()) {\n const count = await restored(resource.name)\n .whereNull('deleted_at')\n .count('* as count')\n .first()\n tables[resource.name] = Number(count?.count ?? 0)\n }\n const attachments = await restored('attachments')\n .whereNull('deleted_at')\n .count('* as count')\n .first()\n tables.attachments = Number(attachments?.count ?? 0)\n report.tables = tables\n if (files) {\n await verifyAttachments(files, extracted, await restored('attachments').select('*'))\n await restoreAttachments(files, extracted, true)\n report.verifiedFiles = files.length\n } else if (await restored('attachments').whereNot('disk', 'local').first()) {\n throw new Error('Legacy snapshot does not contain non-local attachments')\n }\n const candidate = await restored('attachments')\n .whereNull('deleted_at')\n .whereNotNull('record_id')\n .orderBy('id', 'desc')\n .first()\n if (!candidate) {\n if (!this.allowEmpty)\n throw new Error(\n 'No record with an attachment exists in the snapshot; the drill cannot verify a file'\n )\n report.attachment = null\n } else {\n const resource = registry.all().find((entry) => entry.name === candidate.resource)\n if (!resource)\n throw new Error(\n `Attachment ${candidate.id} belongs to unknown resource ${candidate.resource}`\n )\n const record = await restored(resource.name)\n .where('id', candidate.record_id)\n .whereNull('deleted_at')\n .first('id')\n if (!record)\n throw new Error(\n `Record ${candidate.resource}#${candidate.record_id} for attachment ${candidate.id} is missing from the restored database`\n )\n if (!isRelativeDiskPath(candidate.path))\n throw new Error(`Unsafe attachment path: ${candidate.path}`)\n const file = await stat(\n join(extracted, files ? `${candidate.id}.bin` : candidate.path)\n ).catch(() => undefined)\n if (!file || !file.isFile())\n throw new Error(\n `Attachment ${candidate.id} file ${candidate.path} is missing from uploads.tar.gz`\n )\n if (file.size !== Number(candidate.size))\n throw new Error(\n `Attachment ${candidate.id} file ${candidate.path} has ${file.size} bytes, expected ${candidate.size}`\n )\n report.attachment = {\n id: Number(candidate.id),\n resource: String(candidate.resource),\n recordId: Number(candidate.record_id),\n field: String(candidate.field),\n path: String(candidate.path),\n size: Number(candidate.size),\n fileVerified: true,\n }\n }\n report.status = 'passed'\n report.finishedAt = new Date().toISOString()\n await settings.set('backup.lastRestoreTest', report.finishedAt)\n await settings.set('backup.lastRestoreTestReport', report)\n this.logger.success(`Restore drill passed: ${JSON.stringify(report)}`)\n } catch (error) {\n report.error = error instanceof Error ? error.message : String(error)\n report.finishedAt = new Date().toISOString()\n await settings.set('backup.lastRestoreTestReport', report)\n this.logger.error(`Restore drill failed: ${JSON.stringify(report)}`)\n this.exitCode = 1\n } finally {\n if (connected) await db.manager.close(database, true)\n if (created) await live.raw('DROP DATABASE IF EXISTS ?? WITH (FORCE)', [database])\n if (extracted) await rm(extracted, { recursive: true, force: true })\n }\n }\n}\n","database/schema.ts":"import { BaseModel, column } from '@adonisjs/lucid/orm'\nimport { DateTime } from 'luxon'\nexport class UserSchema extends BaseModel {\n static $columns = [\n 'createdAt',\n 'disabledAt',\n 'email',\n 'fullName',\n 'id',\n 'password',\n 'updatedAt',\n ] as const\n $columns = UserSchema.$columns\n @column.dateTime({ autoCreate: true })\n declare createdAt: DateTime\n @column.dateTime()\n declare disabledAt: DateTime | null\n @column()\n declare email: string\n @column()\n declare fullName: string | null\n @column({ isPrimary: true })\n declare id: number\n @column({ serializeAs: null })\n declare password: string\n @column.dateTime({ autoCreate: true, autoUpdate: true })\n declare updatedAt: DateTime | null\n}\n\n","commands/adula_setup.ts":"import { BaseCommand } from '@adonisjs/core/ace'\nimport { readFile } from 'node:fs/promises'\nimport hash from '@adonisjs/core/services/hash'\n\nexport default class Setup extends BaseCommand {\n static commandName = 'adula:setup'\n static description = 'Finish local setup using the private credentials generated by create-app'\n static options = { startApp: true }\n\n async run() {\n if (this.app.inProduction || this.app.inTest) throw new Error('Setup requires development mode')\n const credentials = await readFile(this.app.tmpPath('dev-admin.txt'), 'utf8')\n const email = /^Email: (.+)$/m.exec(credentials)?.[1]\n const password = /^Password: (.+)$/m.exec(credentials)?.[1]\n if (!email || !password || password.length < 24) throw new Error('Missing generated administrator credentials')\n const { default: User } = await import('#models/user')\n const { default: db } = await import('@adonisjs/lucid/services/db')\n const existing = await User.findBy('email', email)\n if (existing) {\n if (!(await hash.verify(existing.password, password))) throw new Error('Existing account does not match setup credentials')\n } else {\n if ((await User.query().first())) throw new Error('Setup cannot create an administrator in an existing application')\n await User.create({ email, password, fullName: 'مدير النظام' })\n }\n process.env.ADULA_ADMIN_EMAIL = email\n const installed = await this.kernel.exec('adula:install', [])\n if (installed.exitCode) throw new Error('Kit installation failed')\n const identity = JSON.parse(await readFile(this.app.makePath('company-identity.json'), 'utf8'))\n await db.from('org_units').whereNull('parent_id').update({ name: identity.company })\n this.logger.success('Local administrator, company, managed skills and shadcn UI are ready')\n }\n}\n","tests/functional/starter.spec.ts":"import { test } from '@japa/runner'\nimport { randomUUID } from 'node:crypto'\nimport User from '#models/user'\nimport db from '@adonisjs/lucid/services/db'\nimport mail from '@adonisjs/mail/services/main'\nimport UserInvitationNotification from '#mails/user_invitation_notification'\n\ntest('starter has healthy services and protects administration', async ({ client, assert }) => {\n const health = await client.get('/health')\n health.assertStatus(200)\n assert.equal(health.body().database, 'ok')\n assert.equal(health.body().redis, 'ok')\n const anonymous = await client.get('/admin/users').redirects(0)\n anonymous.assertStatus(302)\n const password = 'test-only-password-123'\n const member = await User.create({ email: `member-${randomUUID()}@example.test`, password })\n const login = await client.post('/login').withCsrfToken().redirects(0).form({ email: member.email, password })\n login.assertStatus(302)\n login.assertHeader('location', '/')\n const denied = await client.get('/admin/users').loginAs(member).header('Accept', 'application/json')\n denied.assertStatus(403)\n})\n\ntest('fresh installation includes permission-protected email invitations and acceptance', async ({ client, assert }) => {\n const knex = db.connection().getWriteClient()\n const password = 'invitation-password-123'\n const admin = await User.create({ email: `invite-admin-${randomUUID()}@example.test`, password })\n const [role] = await knex('roles').insert({ name: `invite-admin-${randomUUID()}` }).returning('id')\n await knex('role_rules').insert({ role_id: role.id, subject: 'all', action: 'manage' })\n await knex('user_roles').insert({ user_id: admin.id, role_id: role.id })\n const { mails } = mail.fake()\n try {\n const page = await client.get('/users/invite').loginAs(admin).withInertia()\n page.assertStatus(200)\n assert.isTrue(page.body().props.canInviteUsers)\n const email = `invited-${randomUUID()}@example.test`\n const sent = await client.post('/users/invite').loginAs(admin).withCsrfToken().header('Accept', 'application/json').json({ email, fullName: 'مستخدم مدعو' })\n sent.assertStatus(200)\n const [notification] = mails.sent((entry) => entry instanceof UserInvitationNotification) as UserInvitationNotification[]\n const token = notification.invitationUrl.split('/').pop()!\n const accepted = await client.post(`/invitations/${token}`).withCsrfToken().header('Accept', 'application/json').json({ password, passwordConfirmation: password })\n accepted.assertStatus(200)\n const user = await User.verifyCredentials(email, password)\n const denied = await client.post('/users/invite').loginAs(user).withCsrfToken().header('Accept', 'application/json').json({ email: `blocked-${email}`, fullName: 'غير مصرح' })\n denied.assertStatus(403)\n } finally { mail.restore() }\n})\n","package.json":"{\n \"name\": \"adula-app\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=24.0.0\"\n },\n \"scripts\": {\n \"start\": \"node bin/server.js\",\n \"build\": \"node ace build\",\n \"dev\": \"node ace serve --hmr\",\n \"test\": \"node ace test && prettier --write database/schema.ts\",\n \"lint\": \"eslint .\",\n \"format\": \"prettier --write .\",\n \"typecheck\": \"tsc --noEmit && tsc --noEmit --project inertia/tsconfig.json\"\n },\n \"imports\": {\n \"#controllers/*\": \"./app/controllers/*.js\",\n \"#exceptions/*\": \"./app/exceptions/*.js\",\n \"#models/*\": \"./app/models/*.js\",\n \"#mails/*\": \"./app/mails/*.js\",\n \"#services/*\": \"./app/services/*.js\",\n \"#listeners/*\": \"./app/listeners/*.js\",\n \"#events/*\": \"./app/events/*.js\",\n \"#generated/*\": \"./.adonisjs/server/*.js\",\n \"#middleware/*\": \"./app/middleware/*.js\",\n \"#transformers/*\": \"./app/transformers/*.js\",\n \"#validators/*\": \"./app/validators/*.js\",\n \"#providers/*\": \"./providers/*.js\",\n \"#policies/*\": \"./app/policies/*.js\",\n \"#abilities/*\": \"./app/abilities/*.js\",\n \"#database/*\": \"./database/*.js\",\n \"#tests/*\": \"./tests/*.js\",\n \"#start/*\": \"./start/*.js\",\n \"#config/*\": \"./config/*.js\",\n \"#modules/*\": \"./app/modules/*.js\"\n },\n \"devDependencies\": {\n \"@adonisjs/assembler\": \"8.5.0\",\n \"@adonisjs/eslint-config\": \"3.1.0\",\n \"@adonisjs/prettier-config\": \"1.5.0\",\n \"@adonisjs/tsconfig\": \"2.0.0\",\n \"@japa/api-client\": \"3.2.1\",\n \"@japa/assert\": \"4.2.0\",\n \"@japa/browser-client\": \"2.3.0\",\n \"@japa/plugin-adonisjs\": \"5.2.0\",\n \"@japa/runner\": \"5.3.0\",\n \"@poppinss/ts-exec\": \"1.4.4\",\n \"@tailwindcss/vite\": \"4.3.3\",\n \"@types/luxon\": \"3.7.5\",\n \"@types/node\": \"26.2.0\",\n \"@types/react\": \"19.2.18\",\n \"@types/react-dom\": \"19.2.5\",\n \"@vitejs/plugin-react\": \"6.1.0\",\n \"eslint\": \"10.9.0\",\n \"eslint-plugin-react\": \"7.37.5\",\n \"eslint-plugin-react-hooks\": \"7.1.1\",\n \"hot-hook\": \"1.0.0\",\n \"pino-pretty\": \"13.1.3\",\n \"playwright\": \"1.63.0\",\n \"prettier\": \"3.9.6\",\n \"shadcn\": \"4.21.0\",\n \"typescript\": \"6.0.3\",\n \"vite\": \"8.2.2\",\n \"youch\": \"4.1.1\",\n \"pnpm\": \"11.19.0\"\n },\n \"dependencies\": {\n \"@adonisjs/ally\": \"6.3.0\",\n \"@adonisjs/auth\": \"10.1.0\",\n \"@adonisjs/cache\": \"2.1.0\",\n \"@adonisjs/core\": \"7.5.0\",\n \"@adonisjs/cors\": \"3.0.0\",\n \"@adonisjs/drive\": \"4.0.0\",\n \"@adonisjs/inertia\": \"5.0.1\",\n \"@adonisjs/limiter\": \"3.0.1\",\n \"@adonisjs/lucid\": \"22.4.2\",\n \"@adonisjs/mail\": \"10.4.0\",\n \"@adonisjs/redis\": \"10.0.2\",\n \"@adonisjs/session\": \"8.1.0\",\n \"@adonisjs/shield\": \"9.0.0\",\n \"@adonisjs/static\": \"2.0.1\",\n \"@adonisjs/vite\": \"6.0.1\",\n \"@adula/kit\": \"0.2.0-alpha.2\",\n \"@adula/ui\": \"0.2.0-alpha.2\",\n \"@aws-sdk/client-s3\": \"^3.1134.0\",\n \"@aws-sdk/s3-request-presigner\": \"^3.1134.0\",\n \"@casl/ability\": \"7.0.1\",\n \"@casl/react\": \"7.0.1\",\n \"@fontsource/noto-sans-arabic\": \"5.3.0\",\n \"@hookform/resolvers\": \"5.9.1\",\n \"@inertiajs/core\": \"3.7.1\",\n \"@inertiajs/react\": \"3.7.0\",\n \"@jrmc/adonis-attachment\": \"5.2.1\",\n \"@jrmc/adonis-mcp\": \"2.0.0\",\n \"@nemoventures/adonis-jobs\": \"2.2.0\",\n \"@tanstack/react-table\": \"9.2.4\",\n \"@tanstack/react-virtual\": \"3.14.13\",\n \"@tuyau/core\": \"1.2.2\",\n \"@vinejs/vine\": \"4.4.0\",\n \"adonisjs-scheduler\": \"2.8.0\",\n \"axios\": \"1.19.0\",\n \"bullmq\": \"5.81.5\",\n \"class-variance-authority\": \"0.7.1\",\n \"cmdk\": \"1.1.1\",\n \"cn\": \"0.3.0\",\n \"date-fns\": \"4.4.0\",\n \"edge.js\": \"6.5.1\",\n \"lucide-react\": \"1.47.0\",\n \"luxon\": \"3.7.2\",\n \"pg\": \"8.16.3\",\n \"radix-ui\": \"1.6.7\",\n \"react\": \"19.2.8\",\n \"react-day-picker\": \"10.0.1\",\n \"react-dom\": \"19.2.8\",\n \"react-hook-form\": \"7.88.0\",\n \"reflect-metadata\": \"0.2.2\",\n \"sonner\": \"2.0.8\",\n \"tailwindcss\": \"4.3.3\",\n \"tw-animate-css\": \"1.4.0\",\n \"zod\": \"4.6.5\"\n },\n \"hotHook\": {\n \"boundaries\": [\n \"./app/controllers/**/*.ts\",\n \"./app/middleware/*.ts\"\n ]\n },\n \"overrides\": {\n \"eslint-plugin-react\": {\n \"eslint\": \"$eslint\"\n }\n },\n \"prettier\": \"@adonisjs/prettier-config\",\n \"packageManager\": \"pnpm@11.19.0\"\n}\n","pnpm-workspace.yaml":"packages:\n - '.'\nallowBuilds:\n '@swc/core': true\n esbuild: true\n '@parcel/watcher': true\n argon2: true\n msgpackr-extract: false\n exifreader: false\nminimumReleaseAgeExclude:\n - lucide-react@1.47.0\noverrides:\n eslint-plugin-react>eslint: 10.9.0\n monaco-editor>dompurify: 3.4.15\n node-cron>uuid: 11.1.1\n",".gitignore":"node_modules/\nbuild/\n.adonisjs/\n.env*\n!.env.example\ntmp/\nstorage/\npublic/assets/\n*.log\n",".prettierignore":".adonisjs\nnode_modules\nbuild\n.adula-packages\n"}}
|