@initsolusidigital/worksuite-shell 0.0.0-stage → 0.3.0

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.
Files changed (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +122 -2
  3. package/package.json +44 -4
  4. package/src/components/ai/AppAssistantDraft.vue +120 -0
  5. package/src/components/ai/AppAssistantWidget.vue +245 -0
  6. package/src/components/ai/AppCitedAnswer.vue +86 -0
  7. package/src/components/approvals/AppApprovalInbox.vue +265 -0
  8. package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
  9. package/src/components/chat/AppChatBubble.vue +324 -0
  10. package/src/components/chat/AppChatList.vue +249 -0
  11. package/src/components/chat/AppChatPanel.vue +315 -0
  12. package/src/components/chat/AppChatWidget.vue +126 -0
  13. package/src/components/communications/AppChannelBadge.vue +47 -0
  14. package/src/components/communications/AppConversationThread.vue +439 -0
  15. package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
  16. package/src/components/customFields/CustomFieldInput.vue +422 -0
  17. package/src/components/customFields/CustomFieldValue.vue +129 -0
  18. package/src/components/customFields/CustomFieldsSection.vue +65 -0
  19. package/src/components/customFields/fieldTypes.js +23 -0
  20. package/src/components/dashboard/AppCustomWidget.vue +138 -0
  21. package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
  22. package/src/components/dashboard/AppDashboardGrid.vue +200 -0
  23. package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
  24. package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
  25. package/src/components/dataTools/AppImportExport.vue +724 -0
  26. package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
  27. package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
  28. package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
  29. package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
  30. package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
  31. package/src/components/kpi/AppKpiAppraisals.vue +733 -0
  32. package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
  33. package/src/components/kpi/AppKpiEntryModal.vue +122 -0
  34. package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
  35. package/src/components/kpi/AppKpiMyCard.vue +134 -0
  36. package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
  37. package/src/components/kpi/AppKpiReportTable.vue +214 -0
  38. package/src/components/kpi/AppKpiScorecard.vue +242 -0
  39. package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
  40. package/src/components/kpi/AppKpiTeam.vue +221 -0
  41. package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
  42. package/src/components/kpi/AppKpiWorkspace.vue +479 -0
  43. package/src/components/layout/AppGlobalSearch.vue +149 -0
  44. package/src/components/layout/AppLanguagePopup.vue +94 -0
  45. package/src/components/layout/AppLauncherPopup.vue +147 -0
  46. package/src/components/layout/AppLegalConsentModal.vue +484 -0
  47. package/src/components/layout/AppNavbar.vue +402 -0
  48. package/src/components/layout/AppNotificationContent.vue +173 -0
  49. package/src/components/layout/AppNotificationDrawer.vue +98 -0
  50. package/src/components/layout/AppNotificationPopup.vue +67 -0
  51. package/src/components/layout/AppPlatformBanners.vue +192 -0
  52. package/src/components/layout/AppSidebar.vue +226 -0
  53. package/src/components/layout/AppSidebarNav.vue +119 -0
  54. package/src/components/layout/AppUserMenu.vue +89 -0
  55. package/src/components/layout/AppUserMenuContent.vue +59 -0
  56. package/src/composables/useKpi.js +232 -0
  57. package/src/composables/useLgViewport.js +25 -0
  58. package/src/composables/useNavigation.js +177 -0
  59. package/src/layouts/AppLayout.vue +207 -0
  60. package/src/locales/en.json +1362 -0
  61. package/src/locales/id.json +1396 -0
  62. package/src/pages/auth/AccountBlocked.vue +55 -0
  63. package/src/pages/auth/IdentityRedirect.vue +37 -0
  64. package/src/pages/profile/Layout.vue +210 -0
  65. package/src/pages/profile/partials/AccountSection.vue +139 -0
  66. package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
  67. package/src/pages/profile/partials/DevicesCard.vue +222 -0
  68. package/src/pages/profile/partials/PreferencesSection.vue +87 -0
  69. package/src/pages/profile/partials/SecuritySection.vue +93 -0
  70. package/src/pages/profile/partials/SessionsCard.vue +418 -0
  71. package/src/pages/profile/partials/SessionsSection.vue +58 -0
  72. package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
  73. package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
  74. package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
  75. package/src/shellSetup.js +46 -0
@@ -0,0 +1,173 @@
1
+ <script setup>
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { LifeBuoy, Trash2 } from '@lucide/vue'
5
+ import { impersonationApi } from '@initsolusidigital/worksuite-core/api/endpoints/impersonation.js'
6
+ import { useToast } from '@initsolusidigital/worksuite-core/composables/useToast.js'
7
+ import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
8
+ import { userName } from '@initsolusidigital/worksuite-core/utils/userName.js'
9
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
10
+ import AppIconButton from '@initsolusidigital/worksuite-ui/AppIconButton.vue'
11
+ import AppBadge from '@initsolusidigital/worksuite-ui/AppBadge.vue'
12
+ import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
13
+ import AppEmptyState from '@initsolusidigital/worksuite-ui/AppEmptyState.vue'
14
+ import { tenantTimeZone } from '@initsolusidigital/worksuite-core/utils/datetime.js'
15
+
16
+ // Section "Akses Dukungan" — SISI TENANT: permintaan tim dukungan untuk melihat
17
+ // ruang kerja MELALUI akun user yang login, beserta keputusannya.
18
+ //
19
+ // Rumahnya di `/profile` karena endpoint-nya self-scoped platform-wide, persis seperti Sesi &
20
+ // Perangkat: `GET /impersonation_requests/my` hanya mengembalikan permintaan yang ditujukan ke
21
+ // user yang login. Konsekuensinya ia ada di setiap app, dan
22
+ // user bisa menyetujui/menolak dari app mana pun yang kebetulan sedang ia buka.
23
+ const { t, locale } = useI18n()
24
+ const { pushToast } = useToast()
25
+
26
+ const rows = ref([])
27
+ const loading = ref(true)
28
+ const pageError = ref('')
29
+ const busyId = ref(null)
30
+
31
+ const STATUS_COLOR = {
32
+ pending: 'amber', approved: 'blue', active: 'green',
33
+ rejected: 'gray', revoked: 'gray', expired: 'gray',
34
+ }
35
+
36
+ // Yang menunggu keputusan ditampilkan lebih dulu — sisanya riwayat. Tanpa pemisahan ini,
37
+ // permintaan baru bisa tenggelam di bawah belasan baris yang sudah selesai.
38
+ const pending = computed(() => rows.value.filter((r) => r.status === 'pending'))
39
+ const history = computed(() => rows.value.filter((r) => r.status !== 'pending'))
40
+
41
+ function fmtDateTime(iso) {
42
+ if (!iso) return '—'
43
+ return new Date(iso).toLocaleString(locale.value === 'id' ? 'id-ID' : 'en-US', {
44
+ day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short', timeZone: tenantTimeZone(),
45
+ })
46
+ }
47
+
48
+ // ⚠️ Nama datang sebagai `first_name`/`last_name`, bukan `name` — membaca `.name` menghasilkan
49
+ // `undefined` lalu jatuh ke UUID mentah di layar (pakai `utils/userName.js`).
50
+ const impersonatorName = (row) => userName(row.impersonator) || t('profile.supportAccess.supportTeam')
51
+
52
+ async function fetchRows() {
53
+ loading.value = true
54
+ pageError.value = ''
55
+ try {
56
+ const { data } = await impersonationApi.my()
57
+ rows.value = data?.data ?? []
58
+ } catch {
59
+ pageError.value = t('profile.supportAccess.loadError')
60
+ } finally {
61
+ loading.value = false
62
+ }
63
+ }
64
+
65
+ async function act(row, action) {
66
+ busyId.value = row.id
67
+ pageError.value = ''
68
+ try {
69
+ await impersonationApi[action](row.id)
70
+ pushToast({ title: t(`profile.supportAccess.toast.${action}`), variant: 'success' })
71
+ await fetchRows()
72
+ } catch (err) {
73
+ const e = normalizeApiError(err)
74
+ // `already_decided` = permintaan sudah diputuskan di tab/perangkat lain. Bukan kegagalan
75
+ // yang perlu ditakuti — cukup segarkan daftarnya supaya layar menyusul kenyataan.
76
+ if (e.reason === 'already_decided') {
77
+ pageError.value = t('profile.supportAccess.alreadyDecided')
78
+ await fetchRows()
79
+ } else {
80
+ pageError.value = e.backendMessage || t('profile.supportAccess.actionFailed')
81
+ }
82
+ } finally {
83
+ busyId.value = null
84
+ }
85
+ }
86
+
87
+ onMounted(fetchRows)
88
+ </script>
89
+
90
+ <template>
91
+ <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
92
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.supportAccess.title') }}</h2>
93
+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5 mb-5">{{ t('profile.supportAccess.subtitle') }}</p>
94
+
95
+ <!-- Batas yang berlaku ditulis di layar, bukan cuma dijanjikan lisan: sesi hanya-baca,
96
+ berdurasi terbatas, dan bisa dicabut kapan saja. Persetujuan tanpa keterangan batasnya
97
+ bukan persetujuan yang berarti. -->
98
+ <AppAlert variant="info" :message="t('profile.supportAccess.guarantee')" class="mb-5" />
99
+
100
+ <AppAlert v-if="pageError" variant="error" :message="pageError" class="mb-4" />
101
+
102
+ <div v-if="loading" class="py-8 flex items-center justify-center text-gray-400">
103
+ <svg class="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
104
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
105
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
106
+ </svg>
107
+ </div>
108
+
109
+ <template v-else>
110
+ <AppEmptyState v-if="!rows.length" :label="t('profile.supportAccess.empty')">
111
+ <template #icon><LifeBuoy class="w-6 h-6 text-gray-400 dark:text-gray-500" :stroke-width="1.5" /></template>
112
+ </AppEmptyState>
113
+
114
+ <!-- Menunggu keputusan -->
115
+ <ul v-if="pending.length" class="space-y-3 mb-6">
116
+ <li
117
+ v-for="row in pending"
118
+ :key="row.id"
119
+ class="p-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20"
120
+ >
121
+ <p class="text-sm font-medium text-gray-900 dark:text-white">
122
+ {{ t('profile.supportAccess.requestedBy', { name: impersonatorName(row) }) }}
123
+ </p>
124
+ <p class="text-sm text-gray-600 dark:text-gray-300 mt-1">{{ row.reason }}</p>
125
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
126
+ {{ t('profile.supportAccess.durationMeta', { minutes: row.duration_minutes }) }}
127
+ · {{ fmtDateTime(row.created_at) }}
128
+ </p>
129
+ <div class="flex items-center gap-2 mt-3">
130
+ <AppButton variant="primary" size="sm" :loading="busyId === row.id" @click="act(row, 'approve')">
131
+ {{ t('profile.supportAccess.approve') }}
132
+ </AppButton>
133
+ <AppButton variant="secondary" size="sm" :disabled="busyId === row.id" @click="act(row, 'reject')">
134
+ {{ t('profile.supportAccess.reject') }}
135
+ </AppButton>
136
+ </div>
137
+ </li>
138
+ </ul>
139
+
140
+ <!-- Riwayat + pencabutan -->
141
+ <ul v-if="history.length" class="divide-y divide-gray-100 dark:divide-gray-700">
142
+ <li v-for="row in history" :key="row.id" class="flex flex-wrap items-center justify-between gap-3 py-3">
143
+ <div class="min-w-0">
144
+ <p class="text-sm text-gray-800 dark:text-gray-200">{{ impersonatorName(row) }}</p>
145
+ <p class="text-xs text-gray-500 dark:text-gray-400">
146
+ {{ row.reason }}
147
+ </p>
148
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
149
+ {{ row.expires_at
150
+ ? t('profile.supportAccess.expiresAt', { time: fmtDateTime(row.expires_at) })
151
+ : fmtDateTime(row.created_at) }}
152
+ </p>
153
+ </div>
154
+ <div class="flex items-center gap-2 shrink-0">
155
+ <AppBadge :color="STATUS_COLOR[row.status] ?? 'gray'">
156
+ {{ t(`profile.supportAccess.status.${row.status}`) }}
157
+ </AppBadge>
158
+ <!-- Pencabutan hanya ditawarkan saat masih ada yang bisa dicabut. Menampilkannya
159
+ pada izin yang sudah mati membuat user mengira aksesnya masih terbuka. -->
160
+ <AppIconButton
161
+ v-if="['approved', 'active'].includes(row.status)"
162
+ :icon="Trash2"
163
+ :label="t('profile.supportAccess.revoke')"
164
+ variant="danger"
165
+ :loading="busyId === row.id"
166
+ @click="act(row, 'revoke')"
167
+ />
168
+ </div>
169
+ </li>
170
+ </ul>
171
+ </template>
172
+ </div>
173
+ </template>
@@ -0,0 +1,141 @@
1
+ <script setup>
2
+ // Perangkat tepercaya 2FA — daftar perangkat yang boleh MELEWATI langkah kode TOTP (password tetap
3
+ // ditanya, batas device tetap berlaku). Self-scoped `/profile/2fa/trusted-devices`, jadi tiap
4
+ // app memakai kartu yang sama.
5
+ //
6
+ // Kepercayaan hidup sebagai cookie httpOnly milik backend; kartu ini tak pernah membaca cookie
7
+ // itu — ia hanya menampilkan catatan server & mencabutnya. Mencabut tak butuh password: yang
8
+ // terjadi hanya langkah kode ditanya lagi, bukan sesuatu yang bisa disalahgunakan.
9
+ //
10
+ // Daftar bisa kosong tiba-tiba tanpa aksi di layar ini: backend mencabut SEMUA perangkat saat
11
+ // 2FA dimatikan, password diganti/di-reset, atau recovery code dipakai.
12
+ import { ref, computed, onMounted } from 'vue'
13
+ import { useI18n } from 'vue-i18n'
14
+ import { twoFactorApi } from '@initsolusidigital/worksuite-core/api/endpoints/twoFactor.js'
15
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
16
+ import { useToast } from '@initsolusidigital/worksuite-core/composables/useToast.js'
17
+ import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
18
+ import { formatDateTime } from '@initsolusidigital/worksuite-core/utils/datetime.js'
19
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
20
+ import AppIconButton from '@initsolusidigital/worksuite-ui/AppIconButton.vue'
21
+ import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
22
+ import AppEmptyState from '@initsolusidigital/worksuite-ui/AppEmptyState.vue'
23
+ import { MonitorSmartphone, Trash2 } from '@lucide/vue'
24
+
25
+ const { t, locale } = useI18n()
26
+ const authStore = useAuthStore()
27
+ const { pushToast } = useToast()
28
+
29
+ const loading = ref(true)
30
+ const error = ref('')
31
+ const devices = ref([])
32
+ const allowed = ref(true)
33
+ const days = ref(30)
34
+ const busyId = ref(null)
35
+ const busyAll = ref(false)
36
+
37
+ // `remember_device_allowed` di /auth/me = gabungan kebijakan SEMUA tenant orang ini (paling ketat
38
+ // menang); `allowed` dari endpoint daftar memuat hal yang sama — dipakai keduanya supaya catatan
39
+ // tampil walau daftar gagal dimuat.
40
+ const policyBlocked = computed(() =>
41
+ authStore.twoFactor?.remember_device_allowed === false || allowed.value === false
42
+ )
43
+
44
+ async function load() {
45
+ loading.value = true
46
+ error.value = ''
47
+ try {
48
+ const { data } = await twoFactorApi.trustedDevices()
49
+ const d = data.data ?? data
50
+ devices.value = Array.isArray(d.devices) ? d.devices : []
51
+ allowed.value = d.allowed !== false
52
+ if (Number(d.days) > 0) days.value = Number(d.days)
53
+ } catch (err) {
54
+ error.value = normalizeApiError(err).backendMessage || t('profile.twoFactor.trusted.loadFailed')
55
+ } finally {
56
+ loading.value = false
57
+ }
58
+ }
59
+
60
+ async function revoke(id) {
61
+ busyId.value = id
62
+ try {
63
+ await twoFactorApi.revokeTrustedDevice(id)
64
+ devices.value = devices.value.filter(x => x.id !== id)
65
+ pushToast({ title: t('profile.twoFactor.trusted.revoked'), variant: 'success' })
66
+ authStore.fetchUser?.()
67
+ } catch (err) {
68
+ // 404 = sudah dicabut di tempat lain — daftar cukup disegarkan, bukan ditampilkan sebagai galat.
69
+ if (normalizeApiError(err).status === 404) return load()
70
+ pushToast({ title: t('profile.twoFactor.trusted.revokeFailed'), variant: 'alert' })
71
+ } finally {
72
+ busyId.value = null
73
+ }
74
+ }
75
+
76
+ async function revokeAll() {
77
+ busyAll.value = true
78
+ try {
79
+ await twoFactorApi.revokeAllTrustedDevices()
80
+ devices.value = []
81
+ pushToast({ title: t('profile.twoFactor.trusted.revokedAll'), variant: 'success' })
82
+ authStore.fetchUser?.()
83
+ } catch {
84
+ pushToast({ title: t('profile.twoFactor.trusted.revokeFailed'), variant: 'alert' })
85
+ } finally {
86
+ busyAll.value = false
87
+ }
88
+ }
89
+
90
+ function fmt(v) {
91
+ return v ? formatDateTime(v, locale.value) : '—'
92
+ }
93
+
94
+ onMounted(load)
95
+ </script>
96
+
97
+ <template>
98
+ <div class="mt-6 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
99
+ <div class="flex flex-wrap items-start justify-between gap-3">
100
+ <div>
101
+ <h4 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
102
+ <MonitorSmartphone class="w-4 h-4 text-gray-400" />{{ t('profile.twoFactor.trusted.title') }}
103
+ </h4>
104
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('profile.twoFactor.trusted.subtitle', { days }) }}</p>
105
+ </div>
106
+ <AppButton
107
+ v-if="devices.length > 1"
108
+ variant="secondary"
109
+ size="sm"
110
+ :loading="busyAll"
111
+ @click="revokeAll"
112
+ >{{ t('profile.twoFactor.trusted.revokeAll') }}</AppButton>
113
+ </div>
114
+
115
+ <AppAlert v-if="policyBlocked" variant="info" :message="t('profile.twoFactor.trusted.policyBlocked')" class="mt-3" />
116
+ <AppAlert v-if="error" variant="error" :message="error" class="mt-3" />
117
+
118
+ <p v-if="loading" class="mt-3 text-sm text-gray-400">{{ t('common.loading') }}</p>
119
+
120
+ <AppEmptyState
121
+ v-else-if="!devices.length && !error"
122
+ class="mt-3"
123
+ :label="t('profile.twoFactor.trusted.emptyTitle')"
124
+ :description="t('profile.twoFactor.trusted.emptyDesc')"
125
+ />
126
+
127
+ <ul v-else-if="devices.length" class="mt-3 divide-y divide-gray-100 dark:divide-gray-700">
128
+ <li v-for="d in devices" :key="d.id" class="py-3 flex flex-wrap items-start justify-between gap-3">
129
+ <div class="min-w-0">
130
+ <p class="text-sm font-medium text-gray-900 dark:text-white break-words">{{ d.label || t('profile.twoFactor.trusted.unknownDevice') }}</p>
131
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
132
+ <span v-if="d.ip_last">{{ t('profile.twoFactor.trusted.ipLast') }}: {{ d.ip_last }} · </span>
133
+ {{ t('profile.twoFactor.trusted.lastUsed') }}: {{ fmt(d.last_used_at) }} ·
134
+ {{ t('profile.twoFactor.trusted.expires') }}: {{ fmt(d.expires_at) }}
135
+ </p>
136
+ </div>
137
+ <AppIconButton :icon="Trash2" :label="t('profile.twoFactor.trusted.revoke')" variant="danger" :loading="busyId === d.id" @click="revoke(d.id)" />
138
+ </li>
139
+ </ul>
140
+ </div>
141
+ </template>
@@ -0,0 +1,304 @@
1
+ <script setup>
2
+ // Verifikasi Dua Langkah (2FA TOTP) — kontrak `pl3-two-factor-and-google-login.md` §3.
3
+ // Endpoint-nya self-scoped tanpa gate modul (pola `/profile/*`), jadi setiap app memakai
4
+ // layar yang sama.
5
+ //
6
+ // **Enrolment DUA langkah, dan itu disengaja:** rahasia dibuat dulu (`start`), 2FA baru menggigit
7
+ // sesudah user membuktikan aplikasinya menyimpan rahasia itu (`confirm`). Kalau langsung menggigit,
8
+ // orang yang menutup tab di tengah jalan akan terkunci oleh rahasia yang tak pernah ia simpan.
9
+ //
10
+ // ⚠️ **Delapan recovery code tampil SEKALI** (pola `badge_token`) — tak ada endpoint untuk
11
+ // membacanya lagi. Karena itu ada langkah "sudah saya simpan" yang harus ditekan, bukan sekadar
12
+ // tombol tutup: kode yang hilang berarti akun terkunci saat ponselnya hilang.
13
+ import { ref, computed } from 'vue'
14
+ import { useI18n } from 'vue-i18n'
15
+ import { twoFactorApi } from '@initsolusidigital/worksuite-core/api/endpoints/twoFactor.js'
16
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
17
+ import { useToast } from '@initsolusidigital/worksuite-core/composables/useToast.js'
18
+ import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
19
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
20
+ import AppInput from '@initsolusidigital/worksuite-ui/AppInput.vue'
21
+ import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
22
+ import AppBadge from '@initsolusidigital/worksuite-ui/AppBadge.vue'
23
+ import AppModal from '@initsolusidigital/worksuite-ui/AppModal.vue'
24
+ import TrustedDevicesCard from './TrustedDevicesCard.vue'
25
+ import { ShieldCheck, ShieldOff, KeyRound } from '@lucide/vue'
26
+
27
+ const { t } = useI18n()
28
+ const authStore = useAuthStore()
29
+ const { pushToast } = useToast()
30
+
31
+ const status = computed(() => authStore.twoFactor ?? { enabled: false, required: false, enrollment_required: false, recovery_codes_left: 0 })
32
+ const aktif = computed(() => status.value.enabled === true)
33
+ const wajib = computed(() => status.value.required === true)
34
+ const sisaKode = computed(() => Number(status.value.recovery_codes_left ?? 0))
35
+
36
+ const busy = ref(false)
37
+ const formError = ref('')
38
+
39
+ // Alur aktivasi: 'password' → 'scan' → (recovery codes)
40
+ const langkah = ref('') // '' = tak sedang enrol
41
+ const password = ref('')
42
+ const secret = ref('')
43
+ const otpauthUri = ref('')
44
+ // QR digambar SENDIRI di sini, bukan lewat `AppSecretReveal` — komponen itu MODAL sekali-tampil
45
+ // (pola `badge_token`), dan memakainya di sini menutupi field kode di belakangnya: penulis harus
46
+ // menutup modal untuk mengetik, sementara menutupnya membatalkan enrolmen. Terbukti saat uji
47
+ // runtime pertama. Pustaka `qrcode` sudah jadi dependency repo ini (dipakai komponen itu juga).
48
+ const qrDataUrl = ref('')
49
+ const code = ref('')
50
+
51
+ // Kode pemulihan yang sedang ditampilkan (sekali tampil) + gerbang "sudah saya simpan"
52
+ const recoveryCodes = ref([])
53
+ const sudahDisimpan = ref(false)
54
+
55
+ const showDisable = ref(false)
56
+ const showRegenerate = ref(false)
57
+ const passwordAksi = ref('')
58
+
59
+ function bersih() {
60
+ langkah.value = ''
61
+ password.value = ''
62
+ secret.value = ''
63
+ otpauthUri.value = ''
64
+ qrDataUrl.value = ''
65
+ code.value = ''
66
+ formError.value = ''
67
+ }
68
+
69
+ function pesanGalat(err, fallbackKey) {
70
+ const e = normalizeApiError(err)
71
+ if (e.reason === 'invalid_password') return t('profile.twoFactor.invalidPassword')
72
+ if (e.reason === 'two_factor_required_by_policy') return t('profile.twoFactor.requiredByPolicy')
73
+ return e.backendMessage || t(fallbackKey)
74
+ }
75
+
76
+ async function mulai() {
77
+ if (!password.value || busy.value) return
78
+ busy.value = true
79
+ formError.value = ''
80
+ try {
81
+ const { data } = await twoFactorApi.start(password.value)
82
+ const d = data.data ?? data
83
+ secret.value = d.secret ?? ''
84
+ otpauthUri.value = d.otpauth_uri ?? ''
85
+ langkah.value = 'scan'
86
+ password.value = ''
87
+ try {
88
+ const QRCode = (await import('qrcode')).default
89
+ qrDataUrl.value = await QRCode.toDataURL(otpauthUri.value, { width: 220, margin: 1 })
90
+ } catch {
91
+ // Gagal menggambar QR BUKAN alasan menggagalkan enrolmen: kunci manual di bawahnya cukup
92
+ // untuk menambahkan akun ke aplikasi authenticator mana pun.
93
+ qrDataUrl.value = ''
94
+ }
95
+ } catch (err) {
96
+ formError.value = pesanGalat(err, 'profile.twoFactor.startFailed')
97
+ } finally {
98
+ busy.value = false
99
+ }
100
+ }
101
+
102
+ async function konfirmasi() {
103
+ if (!code.value.trim() || busy.value) return
104
+ busy.value = true
105
+ formError.value = ''
106
+ try {
107
+ const { data } = await twoFactorApi.confirm(code.value.trim())
108
+ const d = data.data ?? data
109
+ recoveryCodes.value = d.recovery_codes ?? []
110
+ sudahDisimpan.value = false
111
+ bersih()
112
+ // `/auth/me` disegarkan supaya status (dan `recovery_codes_left`) di seluruh app ikut benar —
113
+ // blok `two_factor` hidup di sessionMeta, bukan di layar ini.
114
+ await authStore.fetchUser()
115
+ pushToast({ title: t('profile.twoFactor.enabled'), variant: 'success' })
116
+ } catch (err) {
117
+ formError.value = pesanGalat(err, 'profile.twoFactor.confirmFailed')
118
+ } finally {
119
+ busy.value = false
120
+ }
121
+ }
122
+
123
+ async function cetakUlang() {
124
+ if (!passwordAksi.value || busy.value) return
125
+ busy.value = true
126
+ formError.value = ''
127
+ try {
128
+ const { data } = await twoFactorApi.regenerateRecoveryCodes(passwordAksi.value)
129
+ const d = data.data ?? data
130
+ recoveryCodes.value = d.recovery_codes ?? []
131
+ sudahDisimpan.value = false
132
+ showRegenerate.value = false
133
+ passwordAksi.value = ''
134
+ await authStore.fetchUser()
135
+ } catch (err) {
136
+ formError.value = pesanGalat(err, 'profile.twoFactor.regenerateFailed')
137
+ } finally {
138
+ busy.value = false
139
+ }
140
+ }
141
+
142
+ async function matikan() {
143
+ if (!passwordAksi.value || busy.value) return
144
+ busy.value = true
145
+ formError.value = ''
146
+ try {
147
+ await twoFactorApi.disable(passwordAksi.value)
148
+ showDisable.value = false
149
+ passwordAksi.value = ''
150
+ await authStore.fetchUser()
151
+ pushToast({ title: t('profile.twoFactor.disabled'), variant: 'success' })
152
+ } catch (err) {
153
+ formError.value = pesanGalat(err, 'profile.twoFactor.disableFailed')
154
+ } finally {
155
+ busy.value = false
156
+ }
157
+ }
158
+
159
+ function salinSecret() {
160
+ navigator.clipboard?.writeText(secret.value)
161
+ pushToast({ title: t('profile.twoFactor.secretCopied'), variant: 'success' })
162
+ }
163
+
164
+ function salinKode() {
165
+ navigator.clipboard?.writeText(recoveryCodes.value.join('\n'))
166
+ pushToast({ title: t('profile.twoFactor.copied'), variant: 'success' })
167
+ }
168
+
169
+ function unduhKode() {
170
+ const blob = new Blob([recoveryCodes.value.join('\n')], { type: 'text/plain' })
171
+ const url = URL.createObjectURL(blob)
172
+ const a = document.createElement('a')
173
+ a.href = url
174
+ a.download = 'worksuite-recovery-codes.txt'
175
+ a.click()
176
+ setTimeout(() => URL.revokeObjectURL(url), 30_000)
177
+ }
178
+ </script>
179
+
180
+ <template>
181
+ <section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
182
+ <div class="flex flex-wrap items-start justify-between gap-3 mb-4">
183
+ <div>
184
+ <div class="flex items-center gap-2">
185
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.twoFactor.title') }}</h2>
186
+ <AppBadge :color="aktif ? 'green' : 'gray'">{{ t(aktif ? 'profile.twoFactor.on' : 'profile.twoFactor.off') }}</AppBadge>
187
+ <AppBadge v-if="wajib" color="amber">{{ t('profile.twoFactor.requiredBadge') }}</AppBadge>
188
+ </div>
189
+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{{ t('profile.twoFactor.subtitle') }}</p>
190
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-1 max-w-prose">{{ t('profile.twoFactor.scopeNote') }}</p>
191
+ </div>
192
+ <div class="flex flex-wrap gap-2">
193
+ <AppButton v-if="!aktif && !langkah" variant="primary" size="sm" @click="langkah = 'password'">
194
+ <ShieldCheck class="w-4 h-4" />{{ t('profile.twoFactor.enable') }}
195
+ </AppButton>
196
+ <template v-if="aktif">
197
+ <AppButton variant="secondary" size="sm" @click="showRegenerate = true">
198
+ <KeyRound class="w-4 h-4" />{{ t('profile.twoFactor.regenerate') }}
199
+ </AppButton>
200
+ <!-- Tombol Matikan DISEMBUNYIKAN saat kebijakan mewajibkan — tapi 422-nya tetap
201
+ ditangani: kebijakan bisa berubah di tab lain, dan tombol yang tampil lalu ditolak
202
+ lebih membingungkan daripada tombol yang memang tak ada. -->
203
+ <AppButton v-if="!wajib" variant="danger" size="sm" @click="showDisable = true">
204
+ <ShieldOff class="w-4 h-4" />{{ t('profile.twoFactor.disable') }}
205
+ </AppButton>
206
+ </template>
207
+ </div>
208
+ </div>
209
+
210
+ <AppAlert v-if="formError" variant="error" :message="formError" class="mb-4" />
211
+ <AppAlert
212
+ v-if="status.enrollment_required"
213
+ variant="warning"
214
+ :message="t('profile.twoFactor.enrollmentRequired')"
215
+ class="mb-4"
216
+ />
217
+ <!-- Peringatan HALUS di bawah 3, bukan blokir: kode habis baru menggigit saat ponselnya hilang,
218
+ dan saat itu sudah terlambat untuk mencetak ulang. -->
219
+ <AppAlert
220
+ v-else-if="aktif && sisaKode < 3"
221
+ variant="warning"
222
+ :message="t('profile.twoFactor.lowRecovery', { n: sisaKode })"
223
+ class="mb-4"
224
+ />
225
+
226
+ <!-- Langkah 1 — password -->
227
+ <div v-if="langkah === 'password'" class="max-w-md space-y-3">
228
+ <p class="text-sm text-gray-600 dark:text-gray-300">{{ t('profile.twoFactor.passwordPrompt') }}</p>
229
+ <AppInput v-model="password" type="password" :label="t('profile.twoFactor.password')" required @keydown.enter="mulai" />
230
+ <div class="flex gap-2">
231
+ <AppButton variant="primary" :loading="busy" :disabled="!password" @click="mulai">{{ t('common.next') }}</AppButton>
232
+ <AppButton variant="secondary" :disabled="busy" @click="bersih">{{ t('common.cancel') }}</AppButton>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Langkah 2 — pindai & konfirmasi -->
237
+ <div v-else-if="langkah === 'scan'" class="max-w-md space-y-3">
238
+ <p class="text-sm text-gray-600 dark:text-gray-300">{{ t('profile.twoFactor.scanPrompt') }}</p>
239
+ <!-- QR digambar DI SINI dari `otpauth_uri` (backend sengaja tak memasang pustaka gambar).
240
+ Kunci manual tetap ditampilkan: pemindaian gagal itu biasa (kamera mati, aplikasi
241
+ desktop), dan tanpa kunci itu enrolmen buntu. -->
242
+ <div class="rounded-lg border border-gray-200 dark:border-gray-700 p-3 flex flex-wrap items-center gap-4">
243
+ <img v-if="qrDataUrl" :src="qrDataUrl" :alt="t('profile.twoFactor.scanTitle')" class="w-40 h-40 rounded bg-white p-1" />
244
+ <div class="min-w-0 flex-1">
245
+ <p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('profile.twoFactor.manualLabel') }}</p>
246
+ <code class="block break-all text-sm font-mono text-gray-800 dark:text-gray-100">{{ secret }}</code>
247
+ <AppButton variant="secondary" size="sm" class="mt-2" @click="salinSecret">{{ t('common.copy') }}</AppButton>
248
+ </div>
249
+ </div>
250
+ <AppInput v-model="code" :label="t('profile.twoFactor.codeLabel')" inputmode="numeric" maxlength="6" required @keydown.enter="konfirmasi" />
251
+ <div class="flex gap-2">
252
+ <AppButton variant="primary" :loading="busy" :disabled="!code.trim()" @click="konfirmasi">{{ t('profile.twoFactor.activate') }}</AppButton>
253
+ <AppButton variant="secondary" :disabled="busy" @click="bersih">{{ t('common.cancel') }}</AppButton>
254
+ </div>
255
+ </div>
256
+
257
+ <!-- Perangkat tepercaya hanya berarti saat 2FA aktif; tanpa 2FA tak ada langkah kode yang bisa
258
+ dilewati, dan kartu kosong di sana hanya membingungkan. -->
259
+ <TrustedDevicesCard v-if="aktif && !langkah" />
260
+
261
+ <!-- Recovery code — TAMPIL SEKALI. Modal tak bisa ditutup sebelum dinyatakan tersimpan. -->
262
+ <AppModal
263
+ v-if="recoveryCodes.length"
264
+ :title="t('profile.twoFactor.recoveryTitle')"
265
+ size="md"
266
+ @close="sudahDisimpan && (recoveryCodes = [])"
267
+ >
268
+ <AppAlert variant="warning" :message="t('profile.twoFactor.recoveryWarning')" class="mb-3" />
269
+ <div class="grid grid-cols-2 gap-2 font-mono text-sm">
270
+ <code v-for="k in recoveryCodes" :key="k" class="px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100">{{ k }}</code>
271
+ </div>
272
+ <div class="mt-3 flex flex-wrap gap-2">
273
+ <AppButton variant="secondary" size="sm" @click="salinKode">{{ t('common.copy') }}</AppButton>
274
+ <AppButton variant="secondary" size="sm" @click="unduhKode">{{ t('profile.twoFactor.downloadCodes') }}</AppButton>
275
+ </div>
276
+ <label class="mt-4 flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
277
+ <input v-model="sudahDisimpan" type="checkbox" class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-primary-600" />
278
+ <span>{{ t('profile.twoFactor.savedConfirm') }}</span>
279
+ </label>
280
+ <template #footer>
281
+ <AppButton variant="primary" :disabled="!sudahDisimpan" @click="recoveryCodes = []">{{ t('profile.twoFactor.doneSaved') }}</AppButton>
282
+ </template>
283
+ </AppModal>
284
+
285
+ <!-- Konfirmasi password: cetak ulang & matikan -->
286
+ <AppModal v-if="showRegenerate" :title="t('profile.twoFactor.regenerate')" size="sm" @close="showRegenerate = false; passwordAksi = ''">
287
+ <AppAlert variant="warning" :message="t('profile.twoFactor.regenerateWarning')" class="mb-3" />
288
+ <AppInput v-model="passwordAksi" type="password" :label="t('profile.twoFactor.password')" required @keydown.enter="cetakUlang" />
289
+ <template #footer>
290
+ <AppButton variant="secondary" :disabled="busy" @click="showRegenerate = false; passwordAksi = ''">{{ t('common.cancel') }}</AppButton>
291
+ <AppButton variant="primary" :loading="busy" :disabled="!passwordAksi" @click="cetakUlang">{{ t('profile.twoFactor.regenerate') }}</AppButton>
292
+ </template>
293
+ </AppModal>
294
+
295
+ <AppModal v-if="showDisable" :title="t('profile.twoFactor.disable')" size="sm" @close="showDisable = false; passwordAksi = ''">
296
+ <AppAlert variant="warning" :message="t('profile.twoFactor.disableWarning')" class="mb-3" />
297
+ <AppInput v-model="passwordAksi" type="password" :label="t('profile.twoFactor.password')" required @keydown.enter="matikan" />
298
+ <template #footer>
299
+ <AppButton variant="secondary" :disabled="busy" @click="showDisable = false; passwordAksi = ''">{{ t('common.cancel') }}</AppButton>
300
+ <AppButton variant="danger" :loading="busy" :disabled="!passwordAksi" @click="matikan">{{ t('profile.twoFactor.disable') }}</AppButton>
301
+ </template>
302
+ </AppModal>
303
+ </section>
304
+ </template>
@@ -0,0 +1,46 @@
1
+ import { registerPackageMessages } from '@initsolusidigital/worksuite-core/plugins/i18n.js'
2
+ import id from './locales/id.json'
3
+ import en from './locales/en.json'
4
+
5
+ // Hal yang berbeda per app dan dibaca berkas shell: data menu, peta breadcrumb, badge menu, dan
6
+ // reaksi sesudah logout. Berkas paket tak boleh mengimpor modul app, jadi semuanya masuk lewat
7
+ // satu panggilan ini di `main.js`, sesudah `configureWorksuite()`.
8
+ //
9
+ // Konfigurasi dibaca saat dipakai, tak pernah di tingkat atas modul: impor dievaluasi sebelum
10
+ // badan `main.js` berjalan, jadi pembacaan di tingkat atas selalu menemukan konfigurasi kosong.
11
+ let config = null
12
+
13
+ /**
14
+ * @param {object} options
15
+ * @param {Array} options.navigation isi `data/navigation.js` app: grup menu sidebar.
16
+ * @param {() => { breadcrumbs: import('vue').ComputedRef<Array> }} options.useBreadcrumbs composable
17
+ * breadcrumb app (`useAppBreadcrumbs`).
18
+ * @param {{ [key: string]: () => import('vue').Ref<number> }} [options.navigationBadges] pembuat
19
+ * ref angka badge per `badgeKey` item menu.
20
+ * @param {(can: Function) => string | null} [options.tenantSettingsEntryRoute] nama rute seksi
21
+ * Setelan Tenant pertama yang boleh dibuka (rute ber-param `companyId`); tanpa opsi ini ikon
22
+ * Setelan Tenant tak tampil di navbar.
23
+ * @param {(ctx: { router: import('vue-router').Router, redirect: () => void }) => void} [options.onLogout]
24
+ * dipanggil sesudah sesi diakhiri dari menu user. `redirect()` = perilaku bawaan (pintu
25
+ * identitas, atau rute `login` app bila pintu itu tak diketahui).
26
+ */
27
+ export function configureShell(options) {
28
+ if (!Array.isArray(options?.navigation)) throw new Error('configureShell: navigation wajib diisi (isi data/navigation.js)')
29
+ if (typeof options.useBreadcrumbs !== 'function') throw new Error('configureShell: useBreadcrumbs wajib diisi (composable useAppBreadcrumbs)')
30
+ config = { ...options }
31
+ }
32
+
33
+ registerPackageMessages('shell', { id, en })
34
+
35
+ function required() {
36
+ if (!config) {
37
+ throw new Error('worksuite-shell belum dikonfigurasi: panggil configureShell({ navigation, useBreadcrumbs }) di main.js')
38
+ }
39
+ return config
40
+ }
41
+
42
+ export const getNavigation = () => required().navigation
43
+ export const getNavigationBadges = () => required().navigationBadges ?? {}
44
+ export const getBreadcrumbs = () => required().useBreadcrumbs
45
+ export const getTenantSettingsEntryRoute = () => required().tenantSettingsEntryRoute ?? null
46
+ export const getLogoutHandler = () => required().onLogout ?? null