@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.
- package/LICENSE +16 -0
- package/README.md +122 -2
- package/package.json +44 -4
- package/src/components/ai/AppAssistantDraft.vue +120 -0
- package/src/components/ai/AppAssistantWidget.vue +245 -0
- package/src/components/ai/AppCitedAnswer.vue +86 -0
- package/src/components/approvals/AppApprovalInbox.vue +265 -0
- package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
- package/src/components/chat/AppChatBubble.vue +324 -0
- package/src/components/chat/AppChatList.vue +249 -0
- package/src/components/chat/AppChatPanel.vue +315 -0
- package/src/components/chat/AppChatWidget.vue +126 -0
- package/src/components/communications/AppChannelBadge.vue +47 -0
- package/src/components/communications/AppConversationThread.vue +439 -0
- package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
- package/src/components/customFields/CustomFieldInput.vue +422 -0
- package/src/components/customFields/CustomFieldValue.vue +129 -0
- package/src/components/customFields/CustomFieldsSection.vue +65 -0
- package/src/components/customFields/fieldTypes.js +23 -0
- package/src/components/dashboard/AppCustomWidget.vue +138 -0
- package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
- package/src/components/dashboard/AppDashboardGrid.vue +200 -0
- package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
- package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
- package/src/components/dataTools/AppImportExport.vue +724 -0
- package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
- package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
- package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
- package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
- package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
- package/src/components/kpi/AppKpiAppraisals.vue +733 -0
- package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
- package/src/components/kpi/AppKpiEntryModal.vue +122 -0
- package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
- package/src/components/kpi/AppKpiMyCard.vue +134 -0
- package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
- package/src/components/kpi/AppKpiReportTable.vue +214 -0
- package/src/components/kpi/AppKpiScorecard.vue +242 -0
- package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
- package/src/components/kpi/AppKpiTeam.vue +221 -0
- package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
- package/src/components/kpi/AppKpiWorkspace.vue +479 -0
- package/src/components/layout/AppGlobalSearch.vue +149 -0
- package/src/components/layout/AppLanguagePopup.vue +94 -0
- package/src/components/layout/AppLauncherPopup.vue +147 -0
- package/src/components/layout/AppLegalConsentModal.vue +484 -0
- package/src/components/layout/AppNavbar.vue +402 -0
- package/src/components/layout/AppNotificationContent.vue +173 -0
- package/src/components/layout/AppNotificationDrawer.vue +98 -0
- package/src/components/layout/AppNotificationPopup.vue +67 -0
- package/src/components/layout/AppPlatformBanners.vue +192 -0
- package/src/components/layout/AppSidebar.vue +226 -0
- package/src/components/layout/AppSidebarNav.vue +119 -0
- package/src/components/layout/AppUserMenu.vue +89 -0
- package/src/components/layout/AppUserMenuContent.vue +59 -0
- package/src/composables/useKpi.js +232 -0
- package/src/composables/useLgViewport.js +25 -0
- package/src/composables/useNavigation.js +177 -0
- package/src/layouts/AppLayout.vue +207 -0
- package/src/locales/en.json +1362 -0
- package/src/locales/id.json +1396 -0
- package/src/pages/auth/AccountBlocked.vue +55 -0
- package/src/pages/auth/IdentityRedirect.vue +37 -0
- package/src/pages/profile/Layout.vue +210 -0
- package/src/pages/profile/partials/AccountSection.vue +139 -0
- package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
- package/src/pages/profile/partials/DevicesCard.vue +222 -0
- package/src/pages/profile/partials/PreferencesSection.vue +87 -0
- package/src/pages/profile/partials/SecuritySection.vue +93 -0
- package/src/pages/profile/partials/SessionsCard.vue +418 -0
- package/src/pages/profile/partials/SessionsSection.vue +58 -0
- package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
- package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
- package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
- 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
|