feat: add browser permission management page for notifications, camera, and location

This commit is contained in:
Yoga Pangestu 2026-06-23 13:02:06 +07:00
parent 9f75a4416a
commit 2a4bcae5ce
6 changed files with 349 additions and 27 deletions

View File

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Admin\Account;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class PermissionsController extends Controller
{
public function index(): Response
{
return Inertia::render('admin/account/Permissions');
}
}

View File

@ -1,7 +1,4 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { Bell, BellOff, LogOut, User } from '@lucide/vue';
import { computed } from 'vue';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@ -9,12 +6,15 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuTrigger
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { usePushNotification } from '@/composables/usePushNotification';
import Separator from './ui/separator/Separator.vue';
import { logout as logoutRoute } from '@/routes';
import { profile } from '@/routes/admin/account';
import { router, usePage } from '@inertiajs/vue3';
import { LogOut, User } from '@lucide/vue';
import { computed } from 'vue';
import Separator from './ui/separator/Separator.vue';
const page = usePage();
@ -26,8 +26,6 @@ const initials = computed(() => {
return username.slice(0, 2).toUpperCase();
});
const { isSupported, isSubscribed, isLoading, toggle } = usePushNotification();
function logout() {
router.post(logoutRoute.url());
}
@ -35,22 +33,6 @@ function logout() {
<template>
<div class="flex items-center gap-2">
<Button
v-if="isSupported"
:disabled="isLoading"
:title="isSubscribed ? 'Matikan notifikasi' : 'Aktifkan notifikasi'"
class="relative size-8 transition-all"
variant="ghost"
@click="toggle"
>
<BellOff v-if="isSubscribed" class="size-4 text-muted-foreground" />
<Bell v-else class="size-4" />
<span
v-if="isSubscribed"
class="absolute top-1 right-1 size-2 rounded-full bg-emerald-500 ring-1 ring-background"
/>
</Button>
<DropdownMenu v-if="user">
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="relative size-8 rounded-full">
@ -65,7 +47,7 @@ function logout() {
<p class="text-sm leading-none font-medium">
{{ user.username }}
</p>
<p class="text-muted-foreground text-xs leading-none">
<p class="text-xs leading-none text-muted-foreground">
{{ user.email }}
</p>
</div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { KeyRound, Palette, User } from '@lucide/vue';
import { KeyRound, Palette, ShieldCheck, User } from '@lucide/vue';
import { computed } from 'vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { cn } from '@/lib/utils';
@ -27,6 +27,11 @@ const navItems = [
label: 'Tampilan',
icon: Palette,
},
{
href: '/admin/account/permissions',
label: 'Izin',
icon: ShieldCheck,
},
];
</script>

View File

@ -0,0 +1,317 @@
<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 AccountLayout from '@/layouts/AccountLayout.vue';
import { usePushNotification } from '@/composables/usePushNotification';
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') {
const result = await Notification.requestPermission();
if (result === 'denied') {
perm.state = 'denied';
} else if (result === 'granted') {
await subscribe();
perm.state = isSubscribed.value ? 'granted' : 'denied';
}
} 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') {
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"
@click="requestPermission(perm)"
>
{{ perm.requesting ? '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>

View File

@ -126,7 +126,7 @@ function submit() {
<FieldError :errors="formErrors(form, 'amount')" />
</Field>
<Field>
<!-- <FieldLabel for="expense-description" required>Keterangan</FieldLabel> -->
<FieldLabel for="expense-description" required>Keterangan</FieldLabel>
<Textarea id="expense-description" v-model="form.description"
placeholder="Contoh: Pembelian perlengkapan toko" rows="3"
:maxlength="FIELD_LIMITS.description" />

View File

@ -3,6 +3,7 @@
use App\Enums\Permission;
use App\Http\Controllers\Admin\Account\AppearanceController;
use App\Http\Controllers\Admin\Account\PasswordController;
use App\Http\Controllers\Admin\Account\PermissionsController;
use App\Http\Controllers\Admin\Account\ProfileController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Finance\CashController;
@ -392,6 +393,8 @@
Route::get('appearance', [AppearanceController::class, 'edit'])->name('appearance');
Route::put('appearance', [AppearanceController::class, 'update'])->name('appearance.update');
Route::get('permissions', [PermissionsController::class, 'index'])->name('permissions');
});
Route::prefix('finance')->name('finance.')->group(function () {