first commit
This commit is contained in:
commit
63e3dc85c5
44 changed files with 3509 additions and 0 deletions
7
src/App.vue
Normal file
7
src/App.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<AdminLayout />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AdminLayout from "@/components/common/AdminLayout.vue";
|
||||
</script>
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
249
src/components/DashboardView.vue
Normal file
249
src/components/DashboardView.vue
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold mb-6">ダッシュボード</div>
|
||||
|
||||
<!-- 統計カード -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card rounded="lg" class="pa-4 text-center" elevation="1">
|
||||
<v-icon size="32" color="primary" class="mb-2">mdi-key-outline</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-primary">{{ totalKeys }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">APIキー総数</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card rounded="lg" class="pa-4 text-center" elevation="1">
|
||||
<v-icon size="32" color="success" class="mb-2">mdi-check-circle-outline</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-success">{{ activeKeys }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">有効なキー</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
rounded="lg"
|
||||
class="pa-4 text-center"
|
||||
elevation="1"
|
||||
style="cursor:pointer"
|
||||
@click="revokedKeyDialog = true"
|
||||
>
|
||||
<v-icon size="32" color="warning" class="mb-2">mdi-lock-outline</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-warning">{{ revokedKeys }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">冻结中のキー</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
rounded="lg"
|
||||
class="pa-4 text-center"
|
||||
elevation="1"
|
||||
style="cursor:pointer"
|
||||
@click="errorLogDialog = true"
|
||||
>
|
||||
<v-icon size="32" color="error" class="mb-2">mdi-alert-circle-outline</v-icon>
|
||||
<div class="text-h4 font-weight-bold text-error">{{ errorLogs }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">エラーログ</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<!-- キー構成 -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card rounded="lg" elevation="1" height="100%">
|
||||
<v-card-title class="pa-4 text-subtitle-1 font-weight-bold">
|
||||
<v-icon class="mr-2">mdi-key-variant</v-icon>
|
||||
キー構成
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon color="primary" size="18">mdi-shield-account</v-icon>
|
||||
<span class="text-body-2">Admin キー</span>
|
||||
</div>
|
||||
<v-chip color="primary" size="small" variant="tonal">
|
||||
{{ adminKeys }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon color="secondary" size="18">mdi-account</v-icon>
|
||||
<span class="text-body-2">User キー</span>
|
||||
</div>
|
||||
<v-chip color="secondary" size="small" variant="tonal">
|
||||
{{ userKeys }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon color="warning" size="18">mdi-lock-outline</v-icon>
|
||||
<span class="text-body-2">冻结中</span>
|
||||
</div>
|
||||
<v-chip color="warning" size="small" variant="tonal">
|
||||
{{ revokedKeys }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- 最近のログ -->
|
||||
<v-col cols="12" md="8">
|
||||
<v-card rounded="lg" elevation="1">
|
||||
<v-card-title class="pa-4 text-subtitle-1 font-weight-bold">
|
||||
<v-icon class="mr-2">mdi-clock-outline</v-icon>
|
||||
最近のログ
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="log in recentLogs"
|
||||
:key="log.id"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-chip
|
||||
:color="log.statusCode < 400 ? 'success' : 'error'"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ log.statusCode }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="text-caption font-weight-medium">
|
||||
{{ log.requestType }} {{ log.endpoint }}
|
||||
</span>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ new Date(log.calledAt).toLocaleString() }}
|
||||
・{{ log.ipAddress ?? "IP不明" }}
|
||||
</span>
|
||||
</template>
|
||||
<template #append>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ log.apiKeyId.slice(0, 8) }}...
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="recentLogs.length === 0">
|
||||
<v-list-item-title class="text-caption text-medium-emphasis">
|
||||
ログなし
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- エラーログ Dialog -->
|
||||
<v-dialog v-model="errorLogDialog" max-width="700">
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 d-flex align-center ga-2">
|
||||
<v-icon color="error">mdi-alert-circle-outline</v-icon>
|
||||
エラーログ一覧
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-0">
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="log in errorLogList"
|
||||
:key="log.id"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-chip color="error" size="x-small" variant="tonal" class="mr-2">
|
||||
{{ log.statusCode }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="text-caption font-weight-medium">
|
||||
{{ log.requestType }} {{ log.endpoint }}
|
||||
</span>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-caption">
|
||||
{{ new Date(log.calledAt).toLocaleString() }}
|
||||
・{{ log.ipAddress ?? "IP不明" }}
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="errorLogList.length === 0">
|
||||
<v-list-item-title class="text-caption text-medium-emphasis">
|
||||
エラーログなし
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="errorLogDialog = false">閉じる</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 冻结キー Dialog -->
|
||||
<v-dialog v-model="revokedKeyDialog" max-width="600">
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 d-flex align-center ga-2">
|
||||
<v-icon color="warning">mdi-lock-outline</v-icon>
|
||||
冻结中のキー一覧
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-0">
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="key in revokedKeyList"
|
||||
:key="key.id"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-chip
|
||||
:color="key.role === 'admin' ? 'primary' : 'secondary'"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ key.role }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="text-caption font-weight-medium">
|
||||
{{ key.name }}
|
||||
</span>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ key.id }}
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="revokedKeyList.length === 0">
|
||||
<v-list-item-title class="text-caption text-medium-emphasis">
|
||||
冻结中のキーなし
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="revokedKeyDialog = false">閉じる</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import {
|
||||
totalKeys, activeKeys, adminKeys, userKeys,
|
||||
revokedKeys, revokedKeyList,
|
||||
errorLogs, errorLogList, recentLogs,
|
||||
errorLogDialog, revokedKeyDialog,
|
||||
dashboardLogic,
|
||||
} from "@/logics/dashboard";
|
||||
|
||||
onMounted(dashboardLogic.fetch);
|
||||
</script>
|
||||
57
src/components/apikey/ApKeyActions.vue
Normal file
57
src/components/apikey/ApKeyActions.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<template>
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
v-if="isUserKey(apiKey)"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('edit')"
|
||||
>
|
||||
<v-icon size="18">mdi-pencil-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="top">編集</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<template v-if="isUserKey(apiKey)">
|
||||
<v-btn
|
||||
icon size="small"
|
||||
variant="text"
|
||||
@click="$emit('toggle-active')"
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="apiKey.isActive ? 'error' : 'success'"
|
||||
>
|
||||
{{ apiKey.isActive ? "mdi-lock-outline" : "mdi-lock-open-outline" }}
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ apiKey.isActive ? "無効化" : "有効化" }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('regenerate')"
|
||||
>
|
||||
<v-icon size="18" color="warning">mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">再生成</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ApiKey } from "@/types/apikey";
|
||||
import { isUserKey } from "@/logics/apikey";
|
||||
|
||||
defineProps<{ apiKey: ApiKey }>();
|
||||
|
||||
defineEmits<{
|
||||
edit: [];
|
||||
delete: [];
|
||||
"toggle-active": [];
|
||||
regenerate: [];
|
||||
}>();
|
||||
</script>
|
||||
90
src/components/apikey/ApiDialog.vue
Normal file
90
src/components/apikey/ApiDialog.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="500"
|
||||
persistent
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 text-h6 d-flex align-center ga-2">
|
||||
<v-icon :color="mode === 'create' ? 'primary' : 'warning'">
|
||||
{{ mode === "create" ? "mdi-plus-circle-outline" : "mdi-pencil-outline" }}
|
||||
</v-icon>
|
||||
{{ mode === "create" ? "APIキー作成" : "APIキー編集" }}
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-form v-model="valid" class="d-flex flex-column ga-4">
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
label="名前 *"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
:rules="[v => !!v || '名前は必須です']"
|
||||
/>
|
||||
<v-select
|
||||
v-model="form.level"
|
||||
label="ロール *"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-shield-account-outline"
|
||||
:items="[
|
||||
{ title: 'Admin', value: 10 },
|
||||
{ title: 'User', value: 1 },
|
||||
]"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.expiresAt"
|
||||
label="有効期限"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-calendar-outline"
|
||||
type="date"
|
||||
clearable
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('cancel')">
|
||||
キャンセル
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:disabled="!valid"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { CreateApiKeyInput } from "@/types/apikey";
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
mode: "create" | "edit";
|
||||
form: CreateApiKeyInput;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
save: [];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const valid = ref(false);
|
||||
</script>
|
||||
99
src/components/apikey/ApiKeyManager.vue
Normal file
99
src/components/apikey/ApiKeyManager.vue
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div class="text-h5 font-weight-bold">APIキー管理</div>
|
||||
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn
|
||||
v-if="tab === 'user' && selectedKeys.length > 0"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
選択中を削除 ({{ selectedKeys.length }})
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="apikeyLogic.openCreate"
|
||||
>
|
||||
新規作成
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-tabs v-model="tab" class="mb-4">
|
||||
<v-tab value="user">
|
||||
<v-icon start>mdi-account</v-icon>
|
||||
User キー
|
||||
</v-tab>
|
||||
<v-tab value="admin">
|
||||
<v-icon start>mdi-shield-account</v-icon>
|
||||
Admin キー
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-window v-model="tab">
|
||||
<v-tabs-window-item value="user">
|
||||
<ApiKeyTable
|
||||
mode="user"
|
||||
:keys="userKeyList"
|
||||
:loading="loading"
|
||||
v-model:selected="selectedKeys"
|
||||
@edit="apikeyLogic.openEdit"
|
||||
@toggle-active="apikeyLogic.toggleActive"
|
||||
@regenerate="apikeyLogic.regenerate"
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item value="admin">
|
||||
<ApiKeyTable
|
||||
mode="admin"
|
||||
:keys="adminKeyList"
|
||||
:loading="loading"
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
|
||||
<ApiDialog
|
||||
v-model="dialog"
|
||||
:mode="dialogMode"
|
||||
:form="form"
|
||||
@save="apikeyLogic.save"
|
||||
@cancel="dialog = false"
|
||||
/>
|
||||
|
||||
<ApiKeyRevealDialog
|
||||
v-model="keyDialog"
|
||||
:api-key="newApiKey"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import ApiKeyTable from "@/components/apikey/ApiKeyTable.vue";
|
||||
import ApiDialog from "@/components/apikey/ApiDialog.vue";
|
||||
import ApiKeyRevealDialog from "@/components/common/ApiKeyRevealDialog.vue";
|
||||
import {
|
||||
keys, loading, dialog, dialogMode, form,
|
||||
keyDialog, newApiKey, apikeyLogic,
|
||||
} from "@/logics/apikey";
|
||||
import type { ApiKey } from "@/types/apikey";
|
||||
|
||||
const tab = ref<"user" | "admin">("user");
|
||||
const selectedKeys = ref<ApiKey[]>([]);
|
||||
|
||||
const userKeyList = computed(() => keys.value.filter(k => k.role === "user"));
|
||||
const adminKeyList = computed(() => keys.value.filter(k => k.role === "admin"));
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const ids = selectedKeys.value.map(k => k.id);
|
||||
await apikeyLogic.deleteSelected(ids);
|
||||
selectedKeys.value = [];
|
||||
};
|
||||
|
||||
onMounted(apikeyLogic.fetchKeys);
|
||||
</script>
|
||||
128
src/components/apikey/ApiKeyTable.vue
Normal file
128
src/components/apikey/ApiKeyTable.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<v-card rounded="lg" elevation="1">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="keys"
|
||||
:loading="loading"
|
||||
item-value="id"
|
||||
hover
|
||||
v-model="internalSelected"
|
||||
:show-select="mode === 'user'"
|
||||
return-object
|
||||
>
|
||||
<template #item.id="{ item }">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ item.id.slice(0, 8) }}...
|
||||
</span>
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="copyId(item.id)"
|
||||
>
|
||||
<v-icon size="12">mdi-content-copy</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.isActive="{ item }">
|
||||
<v-chip
|
||||
:color="item.isActive ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="12">
|
||||
{{ item.isActive ? "mdi-check" : "mdi-lock" }}
|
||||
</v-icon>
|
||||
{{ item.isActive ? "有効" : "凍結中" }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.role="{ item }">
|
||||
<v-chip
|
||||
:color="item.role === 'admin' ? 'primary' : 'secondary'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="12">
|
||||
{{ item.role === "admin"
|
||||
? "mdi-shield-account"
|
||||
: "mdi-account" }}
|
||||
</v-icon>
|
||||
{{ item.role }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.lastUsedAt="{ item }">
|
||||
<span class="text-caption">
|
||||
{{ item.lastUsedAt
|
||||
? new Date(item.lastUsedAt).toLocaleString()
|
||||
: "未使用"
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.createdAt="{ item }">
|
||||
<span class="text-caption">
|
||||
{{ new Date(item.createdAt).toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="mode === 'user'" #item.actions="{ item }">
|
||||
<ApiKeyActions
|
||||
:api-key="item"
|
||||
@edit="$emit('edit', item)"
|
||||
@toggle-active="$emit('toggle-active', item)"
|
||||
@regenerate="$emit('regenerate', item.id)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import ApiKeyActions from "@/components/apikey/ApKeyActions.vue";
|
||||
import type { ApiKey } from "@/types/apikey";
|
||||
|
||||
const props = defineProps<{
|
||||
keys: ApiKey[];
|
||||
loading: boolean;
|
||||
mode: "user" | "admin";
|
||||
selected?: ApiKey[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:selected": [keys: ApiKey[]];
|
||||
edit: [key: ApiKey];
|
||||
"toggle-active": [key: ApiKey];
|
||||
regenerate: [id: string];
|
||||
}>();
|
||||
|
||||
const internalSelected = computed({
|
||||
get: () => props.selected ?? [],
|
||||
set: v => emit("update:selected", v),
|
||||
});
|
||||
|
||||
const headers = computed(() => {
|
||||
const base = [
|
||||
{ title: "ID", key: "id", sortable: false },
|
||||
{ title: "名前", key: "name", sortable: true },
|
||||
{ title: "ロール", key: "role", sortable: true },
|
||||
{ title: "状態", key: "isActive", sortable: true },
|
||||
{ title: "最終使用", key: "lastUsedAt",sortable: true },
|
||||
{ title: "作成日", key: "createdAt", sortable: true },
|
||||
];
|
||||
|
||||
if (props.mode === "user") {
|
||||
base.push({ title: "操作", key: "actions", sortable: false });
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
const copyId = async (id: string) => {
|
||||
await navigator.clipboard.writeText(id);
|
||||
};
|
||||
</script>
|
||||
147
src/components/common/AdminLayout.vue
Normal file
147
src/components/common/AdminLayout.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-navigation-drawer
|
||||
v-model="navOpen"
|
||||
width="240"
|
||||
color="surface"
|
||||
elevation="2"
|
||||
border="0"
|
||||
>
|
||||
<div class="pa-3 d-flex align-center ga-3">
|
||||
<v-avatar color="primary" size="36" rounded="lg">
|
||||
<v-icon color="white" size="20">mdi-shield-key</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
MM Admin
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
管理画面
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-end px-2">
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="navController.close()"
|
||||
>
|
||||
<v-icon size="18">mdi-chevron-left</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="mb-2" />
|
||||
|
||||
<v-list density="compact" nav class="px-2">
|
||||
<v-list-item
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:subtitle="item.subtitle"
|
||||
:to="item.path"
|
||||
rounded="lg"
|
||||
class="mb-1"
|
||||
active-color="primary"
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
<template #append>
|
||||
<ServerStatus />
|
||||
<v-divider />
|
||||
<div class="pa-3 text-caption text-medium-emphasis text-center">
|
||||
MM Knowledge System
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-main
|
||||
class="bg-grey-lighten-4"
|
||||
:class="{ 'nav-closed': !navOpen }"
|
||||
>
|
||||
<v-btn
|
||||
v-if="!navOpen"
|
||||
icon
|
||||
size="small"
|
||||
elevation="2"
|
||||
color="primary"
|
||||
class="nav-open-btn"
|
||||
@click="navController.open()"
|
||||
>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-container fluid class="pa-6">
|
||||
<router-view />
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<div class="popup-container">
|
||||
<v-slide-y-transition group>
|
||||
<v-alert
|
||||
v-for="popup in popupList"
|
||||
:key="popup.id"
|
||||
:type="popup.type"
|
||||
:text="popup.text"
|
||||
closable
|
||||
class="mb-2"
|
||||
elevation="3"
|
||||
@click:close="popupController.remove(popup.id)"
|
||||
/>
|
||||
</v-slide-y-transition>
|
||||
</div>
|
||||
|
||||
<ConfirmationDialog />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConfirmationDialog from "@/components/common/Confirmation.vue";
|
||||
import ServerStatus from "@/components/common/ServerStatus.vue";
|
||||
import { navOpen, navController } from "@/logics/nav";
|
||||
import { popupList, popupController } from "@/logics/popup";
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: "ダッシュボード",
|
||||
subtitle: "概要",
|
||||
icon: "mdi-view-dashboard-outline",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
title: "APIキー管理",
|
||||
subtitle: "作成・管理",
|
||||
icon: "mdi-key-outline",
|
||||
path: "/apikey",
|
||||
},
|
||||
{
|
||||
title: "ログ",
|
||||
subtitle: "アクセス履歴",
|
||||
icon: "mdi-history",
|
||||
path: "/logs",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-closed {
|
||||
padding-left: 48px;
|
||||
}
|
||||
|
||||
.nav-open-btn {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
width: 360px;
|
||||
}
|
||||
</style>
|
||||
104
src/components/common/ApiKeyRevealDialog.vue
Normal file
104
src/components/common/ApiKeyRevealDialog.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
persistent
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 d-flex align-center ga-2">
|
||||
<v-icon color="success">mdi-key-variant</v-icon>
|
||||
APIキーが発行されました
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
text="このキーは一度しか表示されません。必ずコピーして安全な場所に保存してください。"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-text-field
|
||||
:model-value="apiKey"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
hide-details
|
||||
class="flex-grow-1"
|
||||
:type="show ? 'text' : 'password'"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="show = !show"
|
||||
>
|
||||
<v-icon size="18">
|
||||
{{ show ? "mdi-eye-off" : "mdi-eye" }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="small"
|
||||
@click="copyKey"
|
||||
>
|
||||
<v-icon size="18">
|
||||
{{ copied ? "mdi-check" : "mdi-content-copy" }}
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ copied ? "コピー済み" : "コピー" }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
>
|
||||
閉じる
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
apiKey: string | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
}>();
|
||||
|
||||
const show = ref(false);
|
||||
const copied = ref(false);
|
||||
|
||||
const copyKey = async () => {
|
||||
if (!props.apiKey) return;
|
||||
await navigator.clipboard.writeText(props.apiKey);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
};
|
||||
</script>
|
||||
45
src/components/common/Confirmation.vue
Normal file
45
src/components/common/Confirmation.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-if="confirmationState"
|
||||
:model-value="!!confirmationState"
|
||||
max-width="420"
|
||||
persistent
|
||||
>
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-4 d-flex align-center ga-2">
|
||||
<v-icon :color="confirmationState.type">mdi-alert-outline</v-icon>
|
||||
{{ confirmationState.title }}
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4 text-body-1">
|
||||
{{ confirmationState.text }}
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="!confirmationState.confirmOnly"
|
||||
variant="text"
|
||||
@click="confirmationController.resolve(false)"
|
||||
>
|
||||
キャンセル
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:color="confirmationState.type"
|
||||
variant="tonal"
|
||||
@click="confirmationController.resolve(true)"
|
||||
>
|
||||
確認
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { confirmationState, confirmationController } from "@/logics/confirmation";
|
||||
</script>
|
||||
68
src/components/common/ServerStatus.vue
Normal file
68
src/components/common/ServerStatus.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div class="server-status pa-3">
|
||||
|
||||
<div class="d-flex align-center ga-2 mb-1">
|
||||
<v-icon
|
||||
:color="serverOk ? 'success' : 'error'"
|
||||
size="10"
|
||||
>
|
||||
mdi-circle
|
||||
</v-icon>
|
||||
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
Server: {{ serverUrl }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center ga-2 mb-1">
|
||||
<v-icon
|
||||
:color="dbOk ? 'success' : 'error'"
|
||||
size="10"
|
||||
>
|
||||
mdi-circle
|
||||
</v-icon>
|
||||
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
DB: {{ dbName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import api from "@/utils/api";
|
||||
|
||||
const serverUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const serverOk = ref(false);
|
||||
const dbOk = ref(false);
|
||||
|
||||
const dbName = ref("-");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
|
||||
const res = await api.get("/health");
|
||||
|
||||
serverOk.value = res.data.ok ?? false;
|
||||
dbOk.value = res.data.db ?? false;
|
||||
|
||||
dbName.value = res.data.dbName ?? "-";
|
||||
|
||||
} catch {
|
||||
|
||||
serverOk.value = false;
|
||||
dbOk.value = false;
|
||||
|
||||
dbName.value = "-";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-status {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
140
src/logics/apikey.ts
Normal file
140
src/logics/apikey.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { ref } from "vue";
|
||||
import { apikeyService } from "@/services/apikeyService";
|
||||
import { popupController } from "@/logics/popup";
|
||||
import { confirmationController } from "@/logics/confirmation";
|
||||
import type { ApiKey, CreateApiKeyInput } from "@/types/apikey";
|
||||
|
||||
export const keys = ref<ApiKey[]>([]);
|
||||
export const loading = ref(false);
|
||||
export const dialog = ref(false);
|
||||
export const dialogMode = ref<"create" | "edit">("create");
|
||||
export const editingId = ref<string | null>(null);
|
||||
export const newApiKey = ref<string | null>(null);
|
||||
export const keyDialog = ref(false);
|
||||
|
||||
export const isUserKey = (key: ApiKey) => key.level === 1;
|
||||
export const isAdminKey = (key: ApiKey) => key.level === 10;
|
||||
|
||||
export const form = ref<CreateApiKeyInput>({
|
||||
name: "",
|
||||
level: 1,
|
||||
});
|
||||
|
||||
export const apikeyLogic = {
|
||||
|
||||
fetchKeys: async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
keys.value = await apikeyService.getAll();
|
||||
} catch (e: any) {
|
||||
popupController.error(`取得に失敗しました: ${e.message}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
|
||||
openCreate: () => {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
form.value = { name: "", level: 1 };
|
||||
dialog.value = true;
|
||||
},
|
||||
|
||||
openEdit: (key: ApiKey) => {
|
||||
dialogMode.value = "edit";
|
||||
editingId.value = key.id;
|
||||
form.value = { name: key.name, level: key.level };
|
||||
dialog.value = true;
|
||||
},
|
||||
|
||||
save: async () => {
|
||||
try {
|
||||
if (dialogMode.value === "create") {
|
||||
const result = await apikeyService.create(form.value);
|
||||
newApiKey.value = result.apiKey;
|
||||
keyDialog.value = true;
|
||||
} else if (editingId.value) {
|
||||
await apikeyService.update(editingId.value, form.value);
|
||||
popupController.success("更新しました");
|
||||
}
|
||||
dialog.value = false;
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`保存に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 単体削除(既存の関数名維持)
|
||||
delete: async (id: string) => {
|
||||
const ok = await confirmationController.ask(
|
||||
"削除確認",
|
||||
"このAPIキーを削除しますか?この操作は取り消せません。",
|
||||
"error",
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await apikeyService.delete(id);
|
||||
popupController.success("削除しました");
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 複数削除(既存の関数名維持)
|
||||
deleteSelected: async (ids: string[]) => {
|
||||
const ok = await confirmationController.ask(
|
||||
"削除確認",
|
||||
`選択中の${ids.length}件のAPIキーを削除しますか?この操作は取り消せません。`,
|
||||
"error",
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await apikeyService.deleteByIds(ids);
|
||||
popupController.success(`${ids.length}件削除しました`);
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 単体active切り替え(既存の関数名維持)
|
||||
toggleActive: async (key: ApiKey) => {
|
||||
const action = key.isActive ? "無効化" : "有効化";
|
||||
const ok = await confirmationController.ask(
|
||||
`${action}確認`,
|
||||
`このAPIキーを${action}しますか?`,
|
||||
"warning",
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await apikeyService.setActive(key.id, !key.isActive);
|
||||
popupController.success(`${action}しました`);
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`操作に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 単体再生成(既存の関数名維持)
|
||||
regenerate: async (id: string) => {
|
||||
const ok = await confirmationController.ask(
|
||||
"再生成確認",
|
||||
"APIキーを再生成しますか?既存のキーは無効になります。",
|
||||
"warning",
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const result = await apikeyService.regenerate(id);
|
||||
newApiKey.value = result.apiKey;
|
||||
keyDialog.value = true;
|
||||
await apikeyLogic.fetchKeys();
|
||||
} catch (e: any) {
|
||||
popupController.error(`再生成に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
21
src/logics/auth.ts
Normal file
21
src/logics/auth.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// import { ref } from "vue";
|
||||
|
||||
// export const currentApiKey = ref<string | null>(
|
||||
// sessionStorage.getItem("mm_admin_key")
|
||||
// );
|
||||
|
||||
// export const currentLevel = ref<number>(
|
||||
// Number(sessionStorage.getItem("mm_admin_level") ?? 0)
|
||||
// );
|
||||
|
||||
// export const authController = {
|
||||
|
||||
// // 同級以上は操作不可
|
||||
// canOperate: (targetLevel: number): boolean => {
|
||||
// return currentLevel.value > targetLevel;
|
||||
// },
|
||||
// };
|
||||
|
||||
|
||||
|
||||
//TODO:APIKEY複数管理の仕組み考える
|
||||
37
src/logics/confirmation.ts
Normal file
37
src/logics/confirmation.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { ref } from "vue";
|
||||
|
||||
export type ConfirmationType = "warning" | "error" | "info";
|
||||
|
||||
export type ConfirmationState = {
|
||||
title: string;
|
||||
text: string;
|
||||
type: ConfirmationType;
|
||||
confirmOnly: boolean;
|
||||
};
|
||||
|
||||
export const confirmationState = ref<ConfirmationState | null>(null);
|
||||
|
||||
let _resolve: ((v: boolean) => void) | null = null;
|
||||
|
||||
export const confirmationController = {
|
||||
|
||||
ask: (
|
||||
title: string,
|
||||
text: string,
|
||||
type: ConfirmationType = "warning",
|
||||
confirmOnly = false,
|
||||
): Promise<boolean> => {
|
||||
confirmationState.value = { title, text, type, confirmOnly };
|
||||
return new Promise(resolve => {
|
||||
_resolve = resolve;
|
||||
});
|
||||
},
|
||||
|
||||
resolve: (value: boolean) => {
|
||||
confirmationState.value = null;
|
||||
if (_resolve) {
|
||||
_resolve(value);
|
||||
_resolve = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
50
src/logics/dashboard.ts
Normal file
50
src/logics/dashboard.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { ref } from "vue";
|
||||
import { apikeyService } from "@/services/apikeyService";
|
||||
import { logService } from "@/services/logService";
|
||||
import { popupController } from "@/logics/popup";
|
||||
import type { ApiKey } from "@/types/apikey";
|
||||
import type { ApiKeyLog } from "@/types/log";
|
||||
|
||||
export const totalKeys = ref(0);
|
||||
export const activeKeys = ref(0);
|
||||
export const adminKeys = ref(0);
|
||||
export const userKeys = ref(0);
|
||||
export const revokedKeys = ref(0);
|
||||
export const totalLogs = ref(0);
|
||||
export const errorLogs = ref(0);
|
||||
export const recentLogs = ref<ApiKeyLog[]>([]);
|
||||
export const errorLogList = ref<ApiKeyLog[]>([]);
|
||||
export const revokedKeyList = ref<ApiKey[]>([]);
|
||||
export const loading = ref(false);
|
||||
|
||||
export const errorLogDialog = ref(false);
|
||||
export const revokedKeyDialog = ref(false);
|
||||
|
||||
export const dashboardLogic = {
|
||||
|
||||
fetch: async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [keyList, logList] = await Promise.all([
|
||||
apikeyService.getAll(),
|
||||
logService.getAll(100),
|
||||
]);
|
||||
|
||||
totalKeys.value = keyList.length;
|
||||
activeKeys.value = keyList.filter(k => k.isActive).length;
|
||||
adminKeys.value = keyList.filter(k => k.role === "admin").length;
|
||||
userKeys.value = keyList.filter(k => k.role === "user").length;
|
||||
revokedKeys.value = keyList.filter(k => !k.isActive).length;
|
||||
revokedKeyList.value = keyList.filter(k => !k.isActive);
|
||||
totalLogs.value = logList.length;
|
||||
errorLogList.value = logList.filter(l => l.statusCode >= 400);
|
||||
errorLogs.value = errorLogList.value.length;
|
||||
recentLogs.value = logList.slice(0, 5);
|
||||
|
||||
} catch (e: any) {
|
||||
popupController.error(`取得に失敗しました: ${e.message}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
78
src/logics/log.ts
Normal file
78
src/logics/log.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { ref } from "vue";
|
||||
import { logService } from "@/services/logService";
|
||||
import { popupController } from "@/logics/popup";
|
||||
import type { ApiKeyLog } from "@/types/log";
|
||||
|
||||
export const logs = ref<ApiKeyLog[]>([]);
|
||||
export const loading = ref(false);
|
||||
export const selectedLog = ref<ApiKeyLog | null>(null);
|
||||
export const logDialog = ref(false);
|
||||
|
||||
export const filter = ref({
|
||||
apiKeyId: "",
|
||||
start: "",
|
||||
end: "",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
export const logLogic = {
|
||||
|
||||
fetchLogs: async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (filter.value.start && filter.value.end) {
|
||||
logs.value = await logService.getByDateRange(
|
||||
filter.value.start,
|
||||
filter.value.end,
|
||||
filter.value.limit,
|
||||
);
|
||||
} else if (filter.value.apiKeyId) {
|
||||
logs.value = await logService.getByApiKeyId(
|
||||
filter.value.apiKeyId,
|
||||
filter.value.limit,
|
||||
);
|
||||
} else {
|
||||
logs.value = await logService.getAll(filter.value.limit);
|
||||
}
|
||||
} catch (e: any) {
|
||||
popupController.error(`取得に失敗しました: ${e.message}`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
|
||||
openDialog: (log: ApiKeyLog) => {
|
||||
selectedLog.value = log;
|
||||
logDialog.value = true;
|
||||
},
|
||||
|
||||
deleteAll: async () => {
|
||||
try {
|
||||
await logService.deleteAll();
|
||||
popupController.success("全件削除しました");
|
||||
await logLogic.fetchLogs();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
deleteBefore: async (before: string) => {
|
||||
try {
|
||||
await logService.deleteBefore(before);
|
||||
popupController.success("削除しました");
|
||||
await logLogic.fetchLogs();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
deleteByApiKeyId: async (apiKeyId: string) => {
|
||||
try {
|
||||
await logService.deleteByApiKeyId(apiKeyId);
|
||||
popupController.success("削除しました");
|
||||
await logLogic.fetchLogs();
|
||||
} catch (e: any) {
|
||||
popupController.error(`削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
15
src/logics/nav.ts
Normal file
15
src/logics/nav.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { ref } from "vue";
|
||||
|
||||
export const navOpen = ref(true);
|
||||
|
||||
export const navController = {
|
||||
toggle: () => {
|
||||
navOpen.value = !navOpen.value;
|
||||
},
|
||||
open: () => {
|
||||
navOpen.value = true;
|
||||
},
|
||||
close: () => {
|
||||
navOpen.value = false;
|
||||
},
|
||||
};
|
||||
62
src/logics/popup.ts
Normal file
62
src/logics/popup.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { ref, type Ref } from "vue";
|
||||
|
||||
export type PopupType = "success" | "error" | "warning" | "info";
|
||||
export type PopupLocation = "top-right" | "top-left" | "bottom";
|
||||
|
||||
export type Popup = {
|
||||
id: string;
|
||||
type: PopupType;
|
||||
text: string;
|
||||
life: number;
|
||||
position: PopupLocation;
|
||||
dismissable: boolean;
|
||||
};
|
||||
|
||||
export const popupList: Ref<Popup[]> = ref([]);
|
||||
|
||||
let _idCounter = 0;
|
||||
|
||||
export const popupController = {
|
||||
|
||||
new: (
|
||||
type: PopupType,
|
||||
text: string,
|
||||
life = 4000,
|
||||
options?: {
|
||||
position?: PopupLocation;
|
||||
dismissable?: boolean;
|
||||
}
|
||||
) => {
|
||||
const popup: Popup = {
|
||||
id: String(_idCounter++),
|
||||
type,
|
||||
text,
|
||||
life,
|
||||
position: options?.position ?? "top-right",
|
||||
dismissable: options?.dismissable ?? true,
|
||||
};
|
||||
|
||||
popupList.value.push(popup);
|
||||
|
||||
if (life > 0) {
|
||||
setTimeout(() => popupController.remove(popup.id), life);
|
||||
}
|
||||
},
|
||||
|
||||
remove: (id: string) => {
|
||||
const idx = popupList.value.findIndex(p => p.id === id);
|
||||
if (idx !== -1) popupList.value.splice(idx, 1);
|
||||
},
|
||||
|
||||
success: (text: string, life = 4000) =>
|
||||
popupController.new("success", text, life),
|
||||
|
||||
error: (text: string, life = 5000) =>
|
||||
popupController.new("error", text, life),
|
||||
|
||||
warning: (text: string, life = 4000) =>
|
||||
popupController.new("warning", text, life),
|
||||
|
||||
info: (text: string, life = 4000) =>
|
||||
popupController.new("info", text, life),
|
||||
};
|
||||
33
src/main.ts
Normal file
33
src/main.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { createApp } from "vue";
|
||||
import { createVuetify } from "vuetify";
|
||||
import * as components from "vuetify/components";
|
||||
import * as directives from "vuetify/directives";
|
||||
import "vuetify/styles";
|
||||
import "@mdi/font/css/materialdesignicons.css";
|
||||
import router from "./router";
|
||||
import App from "./App.vue";
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: "light",
|
||||
themes: {
|
||||
light: {
|
||||
colors: {
|
||||
primary: "#6366F1",
|
||||
secondary: "#8B5CF6",
|
||||
success: "#10B981",
|
||||
warning: "#F59E0B",
|
||||
error: "#EF4444",
|
||||
info: "#3B82F6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vuetify)
|
||||
.mount("#app");
|
||||
25
src/router/index.ts
Normal file
25
src/router/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/dashboard",
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
component: () => import("@/views/DashboardView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/apikey",
|
||||
component: () => import("@/views/ApiKeyView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/logs",
|
||||
component: () => import("@/views/LogView.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
55
src/services/apikeyService.ts
Normal file
55
src/services/apikeyService.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import api from "@/utils/api";
|
||||
import { ApiKeySchema, type ApiKey, type CreateApiKeyInput } from "@/types/apikey";
|
||||
import { z } from "zod";
|
||||
|
||||
export const apikeyService = {
|
||||
|
||||
getAll: async (): Promise<ApiKey[]> => {
|
||||
const res = await api.get("/apikey");
|
||||
return z.array(ApiKeySchema).parse(res.data.data);
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<ApiKey> => {
|
||||
const res = await api.get(`/apikey/${id}`);
|
||||
return ApiKeySchema.parse(res.data.data);
|
||||
},
|
||||
|
||||
create: async (data: CreateApiKeyInput): Promise<{ apiKey: string }> => {
|
||||
const res = await api.post("/apikey", data);
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<CreateApiKeyInput>): Promise<void> => {
|
||||
await api.patch(`/apikey/${id}`, data);
|
||||
},
|
||||
|
||||
// 単体または複数削除
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete("/apikey", { data: { id } });
|
||||
},
|
||||
|
||||
deleteByIds: async (ids: string[]): Promise<void> => {
|
||||
await api.delete("/apikey", { data: { ids } });
|
||||
},
|
||||
|
||||
// 単体または複数のactive切り替え
|
||||
setActive: async (id: string, active: boolean): Promise<void> => {
|
||||
await api.patch("/apikey/active", { id, active });
|
||||
},
|
||||
|
||||
setActiveByIds: async (ids: string[], active: boolean): Promise<void> => {
|
||||
await api.patch("/apikey/active", { ids, active });
|
||||
},
|
||||
|
||||
// 単体再生成
|
||||
regenerate: async (id: string): Promise<{ apiKey: string }> => {
|
||||
const res = await api.post("/apikey/regenerate", { id });
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
// 複数再生成
|
||||
regenerateByIds: async (ids: string[]): Promise<{ id: string; apiKey: string }[]> => {
|
||||
const res = await api.post("/apikey/regenerate", { ids });
|
||||
return res.data.data;
|
||||
},
|
||||
};
|
||||
37
src/services/logService.ts
Normal file
37
src/services/logService.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import api from "@/utils/api";
|
||||
import { ApiKeyLogSchema, type ApiKeyLog } from "@/types/log";
|
||||
import { z } from "zod";
|
||||
|
||||
export const logService = {
|
||||
|
||||
getAll: async (limit = 100): Promise<ApiKeyLog[]> => {
|
||||
const res = await api.get("/apikey/logs", { params: { limit } });
|
||||
return z.array(ApiKeyLogSchema).parse(res.data.data);
|
||||
},
|
||||
|
||||
getByApiKeyId: async (apiKeyId: string, limit = 100): Promise<ApiKeyLog[]> => {
|
||||
const res = await api.get(`/apikey/logs/key/${apiKeyId}`, { params: { limit } });
|
||||
return z.array(ApiKeyLogSchema).parse(res.data.data);
|
||||
},
|
||||
|
||||
getByDateRange: async (start: string, end: string, limit = 100): Promise<ApiKeyLog[]> => {
|
||||
const res = await api.get("/apikey/logs/range", {
|
||||
params: { start, end, limit }
|
||||
});
|
||||
return z.array(ApiKeyLogSchema).parse(res.data.data);
|
||||
},
|
||||
|
||||
deleteAll: async (): Promise<void> => {
|
||||
await api.delete("/apikey/logs");
|
||||
},
|
||||
|
||||
deleteByIds: async (ids: string[]): Promise<void> => {
|
||||
await api.delete(`/apikey/logs/_`, {
|
||||
params: { ids: ids.join(",") }
|
||||
});
|
||||
},
|
||||
|
||||
deleteBefore: async (before: string): Promise<void> => {
|
||||
await api.delete("/apikey/logs/before", { params: { before } });
|
||||
},
|
||||
};
|
||||
35
src/types/apikey.ts
Normal file
35
src/types/apikey.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export type ApiKeyRole = "user" | "admin";
|
||||
|
||||
const levelToRole = (level: number): ApiKeyRole => {
|
||||
if (level === 10) return "admin";
|
||||
return "user";
|
||||
};
|
||||
|
||||
export const ApiKeySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
level: z.number(),
|
||||
keyHash: z.string(),
|
||||
isActive: z.boolean(),
|
||||
expiresAt: z.string().nullable(),
|
||||
lastUsedAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}).transform(v => ({
|
||||
...v,
|
||||
role: levelToRole(v.level),
|
||||
}));
|
||||
|
||||
export const CreateApiKeySchema = z.object({
|
||||
name: z.string(),
|
||||
level: z.number(),
|
||||
expiresAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateApiKeySchema = CreateApiKeySchema.partial();
|
||||
|
||||
export type ApiKey = z.infer<typeof ApiKeySchema>;
|
||||
export type CreateApiKeyInput = z.infer<typeof CreateApiKeySchema>;
|
||||
export type UpdateApiKeyInput = z.infer<typeof UpdateApiKeySchema>;
|
||||
13
src/types/log.ts
Normal file
13
src/types/log.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ApiKeyLogSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
apiKeyId: z.string().uuid(),
|
||||
endpoint: z.string(),
|
||||
requestType: z.string(),
|
||||
statusCode: z.number(),
|
||||
ipAddress: z.string().nullable(),
|
||||
calledAt: z.string(),
|
||||
});
|
||||
|
||||
export type ApiKeyLog = z.infer<typeof ApiKeyLogSchema>;
|
||||
24
src/utils/api.ts
Normal file
24
src/utils/api.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `APIKEY ${import.meta.env.VITE_ADMIN_API_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
// レスポンスのエラーハンドリング
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const message =
|
||||
error.response?.data?.message ??
|
||||
error.message ??
|
||||
"不明なエラーが発生しました";
|
||||
console.error(`[API Error] ${message}`);
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
11
src/utils/role.ts
Normal file
11
src/utils/role.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export type Role = "admin" | "user";
|
||||
|
||||
export function levelToRole(level: number): Role {
|
||||
if (level >= 10) return "admin";
|
||||
return "user";
|
||||
}
|
||||
|
||||
export function roleToLevel(role: Role): number {
|
||||
if (role === "admin") return 10;
|
||||
return 1;
|
||||
}
|
||||
7
src/views/ApiKeyView.vue
Normal file
7
src/views/ApiKeyView.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<ApiKeyManager />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ApiKeyManager from "@/components/apikey/ApiKeyManager.vue";
|
||||
</script>
|
||||
7
src/views/DashboardView.vue
Normal file
7
src/views/DashboardView.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<Dashboard />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dashboard from "@/components/DashboardView.vue";
|
||||
</script>
|
||||
7
src/views/LogView.vue
Normal file
7
src/views/LogView.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<LogManager />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LogManager from "@/components/logs/LogManager.vue";
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue