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">
|
<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 { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -9,12 +6,15 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator, DropdownMenuTrigger
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { usePushNotification } from '@/composables/usePushNotification';
|
|
||||||
import Separator from './ui/separator/Separator.vue';
|
|
||||||
import { logout as logoutRoute } from '@/routes';
|
import { logout as logoutRoute } from '@/routes';
|
||||||
import { profile } from '@/routes/admin/account';
|
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();
|
const page = usePage();
|
||||||
|
|
||||||
@ -26,8 +26,6 @@ const initials = computed(() => {
|
|||||||
return username.slice(0, 2).toUpperCase();
|
return username.slice(0, 2).toUpperCase();
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSupported, isSubscribed, isLoading, toggle } = usePushNotification();
|
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
router.post(logoutRoute.url());
|
router.post(logoutRoute.url());
|
||||||
}
|
}
|
||||||
@ -35,22 +33,6 @@ function logout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<DropdownMenu v-if="user">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" class="relative size-8 rounded-full">
|
<Button variant="ghost" class="relative size-8 rounded-full">
|
||||||
@ -65,7 +47,7 @@ function logout() {
|
|||||||
<p class="text-sm leading-none font-medium">
|
<p class="text-sm leading-none font-medium">
|
||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground text-xs leading-none">
|
<p class="text-xs leading-none text-muted-foreground">
|
||||||
{{ user.email }}
|
{{ user.email }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
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 { computed } from 'vue';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -27,6 +27,11 @@ const navItems = [
|
|||||||
label: 'Tampilan',
|
label: 'Tampilan',
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/account/permissions',
|
||||||
|
label: 'Izin',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
</script>
|
</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')" />
|
<FieldError :errors="formErrors(form, 'amount')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<!-- <FieldLabel for="expense-description" required>Keterangan</FieldLabel> -->
|
<FieldLabel for="expense-description" required>Keterangan</FieldLabel>
|
||||||
<Textarea id="expense-description" v-model="form.description"
|
<Textarea id="expense-description" v-model="form.description"
|
||||||
placeholder="Contoh: Pembelian perlengkapan toko" rows="3"
|
placeholder="Contoh: Pembelian perlengkapan toko" rows="3"
|
||||||
:maxlength="FIELD_LIMITS.description" />
|
:maxlength="FIELD_LIMITS.description" />
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Controllers\Admin\Account\AppearanceController;
|
use App\Http\Controllers\Admin\Account\AppearanceController;
|
||||||
use App\Http\Controllers\Admin\Account\PasswordController;
|
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\Account\ProfileController;
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\Admin\Finance\CashController;
|
use App\Http\Controllers\Admin\Finance\CashController;
|
||||||
@ -392,6 +393,8 @@
|
|||||||
|
|
||||||
Route::get('appearance', [AppearanceController::class, 'edit'])->name('appearance');
|
Route::get('appearance', [AppearanceController::class, 'edit'])->name('appearance');
|
||||||
Route::put('appearance', [AppearanceController::class, 'update'])->name('appearance.update');
|
Route::put('appearance', [AppearanceController::class, 'update'])->name('appearance.update');
|
||||||
|
|
||||||
|
Route::get('permissions', [PermissionsController::class, 'index'])->name('permissions');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('finance')->name('finance.')->group(function () {
|
Route::prefix('finance')->name('finance.')->group(function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user