store/resources/js/pages/admin/account/Permissions.vue
Yoga Pangestu 2efc13643b
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run
feat: enhance push notification handling and service worker registration with improved error handling and subscription management
2026-07-08 11:28:43 +07:00

335 lines
11 KiB
Vue

<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import {
BellRing,
Camera,
CheckCircle2,
FlaskConical,
MapPin,
XCircle,
} from '@lucide/vue';
import { onMounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { usePushNotification } from '@/composables/usePushNotification';
import AccountLayout from '@/layouts/AccountLayout.vue';
type PermissionState = 'granted' | 'denied' | 'prompt' | 'unsupported';
interface PermissionItem {
key: 'notification' | 'camera' | 'location';
label: string;
description: string;
icon: typeof BellRing;
state: PermissionState;
requesting: boolean;
testing: boolean;
}
const { isSupported, isSubscribed, isLoading, subscribe, unsubscribe } =
usePushNotification();
watch(isSubscribed, (subscribed) => {
const notif = permissions.value.find((p) => p.key === 'notification');
if (!notif) {
return;
}
if (subscribed) {
notif.state = 'granted';
} else if (isSupported.value) {
notif.state = 'prompt';
}
});
const permissions = ref<PermissionItem[]>([
{
key: 'notification',
label: 'Notifikasi',
description:
'Izinkan aplikasi mengirimkan notifikasi push ke perangkat.',
icon: BellRing,
state: 'prompt',
requesting: false,
testing: false,
},
{
key: 'camera',
label: 'Kamera',
description: 'Izinkan kamera untuk melakukan presensi.',
icon: Camera,
state: 'prompt',
requesting: false,
testing: false,
},
{
key: 'location',
label: 'Lokasi',
description: 'Izinkan lokasi untuk melakukan presensi.',
icon: MapPin,
state: 'prompt',
requesting: false,
testing: false,
},
]);
async function checkPermissions() {
for (const perm of permissions.value) {
if (perm.key === 'notification') {
if (!isSupported.value) {
perm.state = 'unsupported';
} else if (isSubscribed.value) {
perm.state = 'granted';
} else {
perm.state = 'prompt';
}
} else if (perm.key === 'camera') {
if (!navigator.mediaDevices?.getUserMedia) {
perm.state = 'unsupported';
} else {
try {
const result = await navigator.permissions.query({
name: 'camera' as PermissionName,
});
perm.state = result.state as PermissionState;
} catch {
perm.state = 'prompt';
}
}
} else if (perm.key === 'location') {
if (!navigator.geolocation) {
perm.state = 'unsupported';
} else {
try {
const result = await navigator.permissions.query({
name: 'geolocation',
});
perm.state = result.state as PermissionState;
} catch {
perm.state = 'prompt';
}
}
}
}
}
async function requestPermission(perm: PermissionItem) {
if (
perm.requesting ||
perm.state === 'denied' ||
perm.state === 'unsupported'
) {
return;
}
perm.requesting = true;
try {
if (perm.key === 'notification') {
await subscribe();
if (isSubscribed.value) {
perm.state = 'granted';
} else if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {
perm.state = 'denied';
} else {
perm.state = 'prompt';
}
} else if (perm.key === 'camera') {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
stream.getTracks().forEach((track) => track.stop());
perm.state = 'granted';
} else if (perm.key === 'location') {
await new Promise<void>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
() => resolve(),
() => reject(),
);
});
perm.state = 'granted';
}
} catch {
perm.state = 'denied';
} finally {
perm.requesting = false;
}
}
async function handleUnsubscribeNotification(perm: PermissionItem) {
perm.requesting = true;
try {
await unsubscribe();
perm.state = 'prompt';
} catch {
// state unchanged on error
} finally {
perm.requesting = false;
}
}
async function testPermission(perm: PermissionItem) {
perm.testing = true;
try {
if (perm.key === 'notification') {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification('Tes Notifikasi', {
body: 'Notifikasi berhasil diterima oleh perangkat Anda.',
icon: '/favicon.ico',
});
} else {
new Notification('Tes Notifikasi', {
body: 'Notifikasi berhasil diterima oleh perangkat Anda.',
icon: '/favicon.ico',
});
}
toast.success('Notifikasi tes berhasil dikirim.');
} else if (perm.key === 'camera') {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
stream.getTracks().forEach((track) => track.stop());
toast.success('Kamera berhasil diakses.');
} else if (perm.key === 'location') {
await new Promise<void>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(pos) => {
toast.success(
`Lokasi berhasil diambil: ${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)}`,
);
resolve();
},
() => reject(new Error('Gagal mengambil lokasi.')),
);
});
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Tes gagal.');
} finally {
perm.testing = false;
}
}
const stateLabel: Record<PermissionState, string> = {
granted: 'Diizinkan',
denied: 'Ditolak',
prompt: 'Belum Diizinkan',
unsupported: 'Tidak Didukung',
};
const stateClass: Record<PermissionState, string> = {
granted: 'text-green-600 dark:text-green-400',
denied: 'text-red-500 dark:text-red-400',
prompt: 'text-muted-foreground',
unsupported: 'text-muted-foreground',
};
onMounted(() => {
checkPermissions();
});
</script>
<template>
<Head title="Izin" />
<AccountLayout>
<div class="grid gap-4">
<Card v-for="perm in permissions" :key="perm.key">
<CardContent>
<div class="flex items-start gap-4">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
>
<component :is="perm.icon" class="size-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-semibold">
{{ perm.label }}
</p>
<span
:class="[
'inline-flex items-center gap-1 text-xs font-medium',
stateClass[perm.state],
]"
>
<CheckCircle2
v-if="perm.state === 'granted'"
class="size-3.5"
/>
<XCircle
v-else-if="perm.state === 'denied'"
class="size-3.5"
/>
{{ stateLabel[perm.state] }}
</span>
</div>
<p class="mt-0.5 text-sm text-muted-foreground">
{{ perm.description }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<template v-if="perm.state === 'granted'">
<Button
variant="outline"
size="sm"
:disabled="perm.testing"
@click="testPermission(perm)"
>
<FlaskConical class="size-3.5" />
{{ perm.testing ? 'Mencoba...' : 'Tes' }}
</Button>
<Button
v-if="perm.key === 'notification'"
variant="outline"
size="sm"
:disabled="isLoading || perm.requesting"
@click="handleUnsubscribeNotification(perm)"
>
{{
perm.requesting || isLoading
? 'Memproses...'
: 'Nonaktifkan'
}}
</Button>
</template>
<Button
v-else-if="perm.state === 'prompt'"
size="sm"
:disabled="perm.requesting || (perm.key === 'notification' && isLoading)"
@click="requestPermission(perm)"
>
{{ (perm.requesting || (perm.key === 'notification' && isLoading)) ? 'Meminta...' : 'Izinkan' }}
</Button>
<span
v-else-if="perm.state === 'denied'"
class="text-xs text-muted-foreground"
>
Ditolak di browser
</span>
<span
v-else-if="perm.state === 'unsupported'"
class="text-xs text-muted-foreground"
>
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</AccountLayout>
</template>