feat: add browser permission management page for notifications, camera, and location
This commit is contained in:
parent
9f75a4416a
commit
2a4bcae5ce
15
app/Http/Controllers/Admin/Account/PermissionsController.php
Normal file
15
app/Http/Controllers/Admin/Account/PermissionsController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
317
resources/js/pages/admin/account/Permissions.vue
Normal file
317
resources/js/pages/admin/account/Permissions.vue
Normal 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>
|
||||
@ -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" />
|
||||
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user