373 lines
16 KiB
Vue
373 lines
16 KiB
Vue
<script setup>
|
|
import { ref, onMounted, computed, h } from 'vue';
|
|
import { usePageTitle } from '../composables/Core/usePageTitle';
|
|
import { useModal } from '../composables/Core/useModal';
|
|
import { useAuth } from '../composables/Core/useAuth';
|
|
import BackButton from '../Components/Core/BackButton.vue';
|
|
import axios from 'axios';
|
|
|
|
usePageTitle('POS Access Keys');
|
|
const modal = useModal();
|
|
const { isUltimate, isSuperOperator, isOperator } = useAuth();
|
|
|
|
const isAdmin = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
|
|
|
|
const keys = ref([]);
|
|
const stores = ref([]);
|
|
const isLoading = ref(true);
|
|
const isSaving = ref(false);
|
|
|
|
const newKey = ref({
|
|
name: '',
|
|
store_hash: '',
|
|
expires_at: ''
|
|
});
|
|
|
|
const searchQuery = ref('');
|
|
|
|
const filteredKeys = computed(() => {
|
|
if (!searchQuery.value) return keys.value;
|
|
const q = searchQuery.value.toLowerCase();
|
|
return keys.value.filter(k =>
|
|
k.name.toLowerCase().includes(q) ||
|
|
(k.store?.name || '').toLowerCase().includes(q) ||
|
|
(k.store?.owner?.name || '').toLowerCase().includes(q) ||
|
|
(k.store?.owner?.nickname || '').toLowerCase().includes(q)
|
|
);
|
|
});
|
|
|
|
const fetchKeys = async () => {
|
|
isLoading.value = true;
|
|
try {
|
|
const response = await axios.post('/api/pos/access-keys/list');
|
|
keys.value = response.data.data || [];
|
|
} catch (error) {
|
|
console.error('Failed to fetch keys:', error);
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const fetchStores = async () => {
|
|
try {
|
|
// Admin roles see all stores; store owner/manager see only their own
|
|
const endpoint = isAdmin.value ? '/ListStores/List/data' : '/ListStores/MyStores/data';
|
|
const response = await axios.post(endpoint);
|
|
stores.value = response.data || [];
|
|
} catch (error) {
|
|
console.error('Failed to fetch stores:', error);
|
|
}
|
|
};
|
|
|
|
const createKey = async () => {
|
|
if (!newKey.value.name || !newKey.value.store_hash) {
|
|
modal.open({ title: 'Error', body: 'Please fill in all fields' });
|
|
return;
|
|
}
|
|
|
|
isSaving.value = true;
|
|
try {
|
|
const payload = {
|
|
name: newKey.value.name,
|
|
store_hash: newKey.value.store_hash,
|
|
};
|
|
if (newKey.value.expires_at) {
|
|
payload.expires_at = newKey.value.expires_at;
|
|
}
|
|
const response = await axios.post('/api/pos/access-keys/create', payload);
|
|
if (response.data.success) {
|
|
modal.quickDismiss({ title: 'Success', body: 'Access key created' });
|
|
newKey.value = { name: '', store_hash: '', expires_at: '' };
|
|
await fetchKeys();
|
|
}
|
|
} catch (error) {
|
|
modal.open({ title: 'Error', body: 'Failed to create key' });
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
};
|
|
|
|
const deleteKey = (hashkey) => {
|
|
modal.yesNoModal({
|
|
title: 'Confirm Delete',
|
|
body: 'Are you sure you want to delete this access key?',
|
|
onYes: async () => {
|
|
try {
|
|
await axios.post('/api/pos/access-keys/delete', { target: hashkey });
|
|
await fetchKeys();
|
|
} catch (error) {
|
|
modal.open({ title: 'Error', body: 'Failed to delete key' });
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const toggleStatus = async (hashkey) => {
|
|
try {
|
|
await axios.post('/api/pos/access-keys/toggle', { target: hashkey });
|
|
await fetchKeys();
|
|
} catch (error) {
|
|
modal.open({ title: 'Error', body: 'Failed to update status' });
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = (text) => {
|
|
navigator.clipboard.writeText(text);
|
|
modal.quickDismiss({ title: 'Copied', body: 'Access key copied to clipboard' });
|
|
};
|
|
|
|
const showQrCode = (accessKey) => {
|
|
const url = getPosUrl(accessKey);
|
|
modal.open({
|
|
title: 'POS Access QR Code',
|
|
body: h('div', { class: 'text-center p-3' }, [
|
|
h('div', { class: 'bg-white p-3 d-inline-block rounded shadow-sm border mb-3' }, [
|
|
h('img', {
|
|
src: `https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(url)}`,
|
|
style: { width: '250px', height: '250px' },
|
|
alt: 'POS Access QR Code'
|
|
})
|
|
]),
|
|
h('div', { class: 'fw_6 text-dark mb-1' }, 'Scan to Access POS Terminal'),
|
|
h('div', { class: 'small text-muted mb-3' }, 'Use a mobile device or tablet to quickly open this terminal'),
|
|
h('div', { class: 'p-2 bg-light rounded small text-break border' }, url)
|
|
])
|
|
});
|
|
};
|
|
|
|
const getPosUrl = (accessKey) => {
|
|
const baseUrl = window.location.origin;
|
|
return `${baseUrl}/pos?key=${accessKey}`;
|
|
};
|
|
|
|
const canShare = ref(false);
|
|
|
|
const shareKey = async (accessKey, terminalName) => {
|
|
const url = getPosUrl(accessKey);
|
|
if (navigator.share) {
|
|
try {
|
|
await navigator.share({
|
|
title: 'POS Terminal Access',
|
|
text: `Scan or click to open POS Terminal: ${terminalName}`,
|
|
url: url
|
|
});
|
|
} catch (error) {
|
|
if (error.name !== 'AbortError') {
|
|
console.error('Error sharing:', error);
|
|
modal.open({ title: 'Error', body: 'Could not open share dialog.' });
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const openPosInNewTab = (accessKey) => {
|
|
window.open(getPosUrl(accessKey), '_blank');
|
|
};
|
|
|
|
const isKeyExpired = (key) => {
|
|
if (!key.expires_at) return false;
|
|
return new Date(key.expires_at) < new Date();
|
|
};
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return 'Never';
|
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
onMounted(() => {
|
|
fetchKeys();
|
|
fetchStores();
|
|
canShare.value = !!navigator.share;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="container-fluid py-4">
|
|
<div class="d-flex align-items-center mb-4">
|
|
<BackButton />
|
|
<h2 class="mb-0 ms-3 fw_7">POS Access Key Management</h2>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- Create New Key -->
|
|
<div class="col-md-4 mb-4">
|
|
<div class="card shadow-sm border-0 rounded-xl">
|
|
<div class="card-header bg-white border-0 pt-4 px-4 pb-0">
|
|
<h5 class="fw_6 mb-0">Create New Terminal Key</h5>
|
|
</div>
|
|
<div class="card-body p-4">
|
|
<div class="mb-3">
|
|
<label class="form-label small fw_6 text-muted">Terminal Name (e.g. Counter 1)</label>
|
|
<input v-model="newKey.name" type="text" class="form-control rounded-pill" placeholder="Enter terminal name">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label small fw_6 text-muted">Select Store</label>
|
|
<select v-model="newKey.store_hash" class="form-select rounded-pill">
|
|
<option value="" disabled>Select a store</option>
|
|
<option v-for="store in stores" :key="store.hashkey" :value="store.hashkey">
|
|
{{ store.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="form-label small fw_6 text-muted">Expiry Date (optional)</label>
|
|
<input v-model="newKey.expires_at" type="datetime-local" class="form-control rounded-pill">
|
|
<div class="form-text small">Leave empty for no expiration</div>
|
|
</div>
|
|
<button @click="createKey" class="btn btn-primary w-100 rounded-pill py-2 fw_6" :disabled="isSaving">
|
|
{{ isSaving ? 'Creating...' : 'Generate New Key' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Keys List -->
|
|
<div class="col-md-8">
|
|
<div class="card shadow-sm border-0 rounded-xl mb-4">
|
|
<div class="card-body p-4">
|
|
<div class="d-flex align-items-center justify-content-between mb-0">
|
|
<h5 class="fw_6 mb-0">Active Terminal Keys</h5>
|
|
<div class="search-box position-relative" style="min-width: 250px;">
|
|
<i class="fas fa-search position-absolute top-50 start-0 translate-middle-y ms-3 text-muted opacity-50"></i>
|
|
<input v-model="searchQuery" type="text" class="form-control rounded-pill ps-5" placeholder="Search by name, store or owner...">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card shadow-sm border-0 rounded-xl">
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead class="bg-light">
|
|
<tr>
|
|
<th class="ps-4 py-3 border-0">Terminal / Store</th>
|
|
<th class="py-3 border-0">Access Key</th>
|
|
<th class="py-3 border-0">Status</th>
|
|
<th class="py-3 border-0">Expires</th>
|
|
<th class="py-3 border-0">Last Used</th>
|
|
<th class="pe-4 py-3 border-0 text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="isLoading">
|
|
<td colspan="6" class="text-center py-5">
|
|
<div class="spinner-border spinner-border-sm text-primary me-2"></div>
|
|
Loading keys...
|
|
</td>
|
|
</tr>
|
|
<tr v-else-if="filteredKeys.length === 0">
|
|
<td colspan="6" class="text-center py-5 text-muted">
|
|
{{ searchQuery ? 'No search results found.' : 'No POS access keys found.' }}
|
|
</td>
|
|
</tr>
|
|
<tr v-for="key in filteredKeys" :key="key.hashkey" :class="{ 'table-row-expired': isKeyExpired(key) }">
|
|
<td class="ps-4">
|
|
<div class="fw_6">{{ key.name }}</div>
|
|
<div class="small text-muted d-flex align-items-center">
|
|
<i class="fas fa-store me-1 opacity-50"></i> {{ key.store?.name || 'Unknown Store' }}
|
|
<template v-if="key.store?.owner">
|
|
<span class="mx-1 opacity-25">|</span>
|
|
<i class="fas fa-user-circle me-1 opacity-50"></i> {{ key.store.owner.nickname || key.store.owner.name }}
|
|
</template>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<code class="bg-light px-2 py-1 rounded small">{{ key.access_key }}</code>
|
|
<button @click="copyToClipboard(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="Copy Key">
|
|
<i class="far fa-copy"></i>
|
|
</button>
|
|
<button @click="copyToClipboard(getPosUrl(key.access_key))" class="btn btn-sm btn-link p-0 text-muted" title="Copy POS URL">
|
|
<i class="fas fa-link"></i>
|
|
</button>
|
|
<button @click="showQrCode(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="View QR Code">
|
|
<i class="fas fa-qrcode"></i>
|
|
</button>
|
|
<button @click="openPosInNewTab(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="Open POS Terminal">
|
|
<i class="fas fa-external-link-alt"></i>
|
|
</button>
|
|
<button v-if="canShare" @click="shareKey(key.access_key, key.name)" class="btn btn-sm btn-link p-0 text-muted" title="Share via Protocol">
|
|
<i class="fas fa-share-alt"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span @click="toggleStatus(key.hashkey)" :class="['badge rounded-pill cursor-pointer', key.status === 'active' ? 'bg-soft-success text-success' : 'bg-soft-danger text-danger']">
|
|
{{ key.status }}
|
|
</span>
|
|
<span v-if="isKeyExpired(key)" class="badge bg-warning text-dark rounded-pill ms-1">expired</span>
|
|
</td>
|
|
<td class="small text-muted">
|
|
{{ key.expires_at ? formatDate(key.expires_at) : 'No Expiry' }}
|
|
</td>
|
|
<td class="small text-muted">
|
|
{{ key.last_used_at ? formatDate(key.last_used_at) : 'Never' }}
|
|
</td>
|
|
<td class="pe-4 text-end">
|
|
<button @click="deleteKey(key.hashkey)" class="btn btn-sm btn-soft-danger rounded-circle">
|
|
<i class="fas fa-trash-alt"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 p-4 bg-soft-info rounded-xl border border-info border-opacity-25">
|
|
<h6 class="fw_7"><i class="fas fa-info-circle me-2"></i> How to use:</h6>
|
|
<p class="small mb-0 text-muted">
|
|
Copy the <strong>POS URL</strong> and use it on the target POS machine.
|
|
The machine will be automatically logged into the POS interface for the assigned store using the generated access key.
|
|
Ensure the status is set to <strong>active</strong> for the key to work.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.rounded-xl {
|
|
border-radius: 1rem;
|
|
}
|
|
.bg-soft-success {
|
|
background-color: #e8f5e9;
|
|
}
|
|
.bg-soft-danger {
|
|
background-color: #ffebee;
|
|
}
|
|
.bg-soft-info {
|
|
background-color: #e3f2fd;
|
|
}
|
|
.text-success { color: #2e7d32; }
|
|
.text-danger { color: #c62828; }
|
|
.cursor-pointer { cursor: pointer; }
|
|
.btn-soft-danger {
|
|
background: #fff0f0;
|
|
color: #e74c3c;
|
|
border: none;
|
|
width: 32px;
|
|
height: 32px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.btn-soft-danger:hover {
|
|
background: #e74c3c;
|
|
color: white;
|
|
}
|
|
.table-row-expired {
|
|
opacity: 0.6;
|
|
background-color: #fff8e1;
|
|
}
|
|
.bg-warning {
|
|
background-color: #ffc107 !important;
|
|
}
|
|
</style>
|