develop-updateui #1
2
components.d.ts
vendored
2
components.d.ts
vendored
@@ -27,6 +27,7 @@ declare module 'vue' {
|
|||||||
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
||||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
|
AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default']
|
||||||
BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default']
|
BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default']
|
||||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||||
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
||||||
@@ -108,6 +109,7 @@ declare global {
|
|||||||
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
||||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
|
const AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default']
|
||||||
const BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default']
|
const BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default']
|
||||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||||
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
||||||
|
|||||||
@@ -716,7 +716,7 @@
|
|||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"searchPlaceholder": "Search videos...",
|
"searchPlaceholder": "Search videos...",
|
||||||
"rangeOfTotal": "{first}–{last} of {{total}}",
|
"rangeOfTotal": "{{first}}–{{last}} of {{total}}",
|
||||||
"previousPageAria": "Previous page",
|
"previousPageAria": "Previous page",
|
||||||
"nextPageAria": "Next page",
|
"nextPageAria": "Next page",
|
||||||
"allStatus": "All Status",
|
"allStatus": "All Status",
|
||||||
|
|||||||
@@ -11,20 +11,23 @@ export function httpClientAdapter(opts: {
|
|||||||
}): TinyRpcClientAdapter {
|
}): TinyRpcClientAdapter {
|
||||||
const JSON: JsonTransformer = {
|
const JSON: JsonTransformer = {
|
||||||
parse: globalThis.JSON.parse,
|
parse: globalThis.JSON.parse,
|
||||||
stringify: globalThis.JSON.stringify,
|
stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
|
||||||
...opts.JSON,
|
...opts.JSON,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
send: async (data) => {
|
send: async (data) => {
|
||||||
const url = [opts.url, data.path].join("/");
|
const url = [opts.url, data.path].join("/");
|
||||||
const payload = JSON.stringify(data.args);
|
const extraHeaders = opts.headers ? await opts.headers() : {};
|
||||||
console.log("RPC Request:", payload);
|
const payload = JSON.stringify(data.args, (headerObj) => {
|
||||||
|
if (headerObj) {
|
||||||
|
Object.assign(extraHeaders, headerObj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const method = opts.pathsForGET?.includes(data.path)
|
const method = opts.pathsForGET?.includes(data.path)
|
||||||
? "GET"
|
? "GET"
|
||||||
: "POST";
|
: "POST";
|
||||||
|
|
||||||
const extraHeaders = opts.headers ? await opts.headers() : {};
|
|
||||||
|
|
||||||
let req: Request;
|
let req: Request;
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
req = new Request(
|
req = new Request(
|
||||||
@@ -47,6 +50,7 @@ export function httpClientAdapter(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
|
||||||
res = await fetch(req);
|
res = await fetch(req);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// throw new Error(`HTTP error: ${res.status}`);
|
// throw new Error(`HTTP error: ${res.status}`);
|
||||||
@@ -60,8 +64,10 @@ export function httpClientAdapter(opts: {
|
|||||||
);
|
);
|
||||||
// throw TinyRpcError.deserialize(res.status);
|
// throw TinyRpcError.deserialize(res.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Result<unknown, unknown> = JSON.parse(
|
const result: Result<unknown, unknown> = JSON.parse(
|
||||||
await res.text()
|
await res.text(),
|
||||||
|
() => Object.fromEntries((res.headers as any).entries() ?? [])
|
||||||
);
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw TinyRpcError.deserialize(result.value);
|
throw TinyRpcError.deserialize(result.value);
|
||||||
|
|||||||
@@ -13,14 +13,18 @@ export function httpClientAdapter(opts: {
|
|||||||
}): TinyRpcClientAdapter {
|
}): TinyRpcClientAdapter {
|
||||||
const JSON: JsonTransformer = {
|
const JSON: JsonTransformer = {
|
||||||
parse: globalThis.JSON.parse,
|
parse: globalThis.JSON.parse,
|
||||||
stringify: globalThis.JSON.stringify,
|
stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
|
||||||
...opts.JSON,
|
...opts.JSON,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
send: async (data) => {
|
send: async (data) => {
|
||||||
const url = [opts.url, data.path].join("/");
|
const url = [opts.url, data.path].join("/");
|
||||||
const payload = JSON.stringify(data.args);
|
const extraHeaders = opts.headers ? await opts.headers() : {};
|
||||||
console.log("RPC Request:", payload);
|
const payload = JSON.stringify(data.args, (headerObj) => {
|
||||||
|
if (headerObj) {
|
||||||
|
Object.assign(extraHeaders, headerObj);
|
||||||
|
}
|
||||||
|
});
|
||||||
const method = opts.pathsForGET?.includes(data.path)
|
const method = opts.pathsForGET?.includes(data.path)
|
||||||
? "GET"
|
? "GET"
|
||||||
: "POST";
|
: "POST";
|
||||||
@@ -29,7 +33,10 @@ export function httpClientAdapter(opts: {
|
|||||||
req = new Request(
|
req = new Request(
|
||||||
url +
|
url +
|
||||||
"?" +
|
"?" +
|
||||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
|
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
|
||||||
|
{
|
||||||
|
headers: extraHeaders
|
||||||
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
req = new Request(url, {
|
req = new Request(url, {
|
||||||
@@ -37,6 +44,7 @@ export function httpClientAdapter(opts: {
|
|||||||
body: payload,
|
body: payload,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
...extraHeaders,
|
||||||
},
|
},
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
@@ -67,11 +75,9 @@ export function httpClientAdapter(opts: {
|
|||||||
);
|
);
|
||||||
// throw TinyRpcError.deserialize(res.status);
|
// throw TinyRpcError.deserialize(res.status);
|
||||||
}
|
}
|
||||||
// if (res.headers.get("set-cookie")) {
|
|
||||||
// console.log("Response has set-cookie header:", res.headers.get("set-cookie"));
|
|
||||||
// }
|
|
||||||
const result: Result<unknown, unknown> = JSON.parse(
|
const result: Result<unknown, unknown> = JSON.parse(
|
||||||
await res.text()
|
await res.text(),
|
||||||
|
() => Object.fromEntries((res.headers as any).entries() ?? [])
|
||||||
);
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw TinyRpcError.deserialize(result.value);
|
throw TinyRpcError.deserialize(result.value);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const client = proxyTinyRpc<RpcRoutes>({
|
|||||||
url: `${url}${targetEndpoint}`,
|
url: `${url}${targetEndpoint}`,
|
||||||
pathsForGET: ["health"],
|
pathsForGET: ["health"],
|
||||||
JSON: clientJSON,
|
JSON: clientJSON,
|
||||||
|
headers: () => Promise.resolve({})
|
||||||
}).send(data);
|
}).send(data);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
84
src/components/ui/AsyncSelect.vue
Normal file
84
src/components/ui/AsyncSelect.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
|
||||||
|
// Định nghĩa cấu trúc dữ liệu
|
||||||
|
interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loadOptions: () => Promise<SelectOption[]>;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
placeholder: 'Vui lòng chọn...',
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sử dụng defineModel thay cho props/emits thủ công
|
||||||
|
const modelValue = defineModel<string | number>();
|
||||||
|
|
||||||
|
const options = ref<SelectOption[]>([]);
|
||||||
|
const loading = ref<boolean>(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
options.value = await props.loadOptions();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Lỗi kết nối';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(fetchData);
|
||||||
|
|
||||||
|
// Tự động load lại nếu hàm fetch thay đổi
|
||||||
|
watch(() => props.loadOptions, fetchData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="relative w-full max-w-64">
|
||||||
|
<select
|
||||||
|
v-model="modelValue"
|
||||||
|
:disabled="loading || disabled"
|
||||||
|
class="w-full appearance-none rounded-lg border border-gray-300 bg-white px-4 py-2 pr-10
|
||||||
|
text-gray-700 outline-none transition-all
|
||||||
|
focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
|
||||||
|
disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<option value="" disabled>{{ placeholder }}</option>
|
||||||
|
<option
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">
|
||||||
|
<div class="i-carbon-chevron-down text-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex items-center text-blue-500">
|
||||||
|
<div class="i-carbon-circle-dash animate-spin text-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="error"
|
||||||
|
@click="fetchData"
|
||||||
|
class="text-xs text-red-500 underline hover:text-red-600 transition"
|
||||||
|
>
|
||||||
|
Thử lại?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -310,24 +310,24 @@ const columns = computed<ColumnDef<AdminAdTemplateRow>[]>(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useAdminPageHeader(() => ({
|
// useAdminPageHeader(() => ({
|
||||||
eyebrow: "Advertising",
|
// eyebrow: "Advertising",
|
||||||
badge: `${total.value} total templates`,
|
// badge: `${total.value} total templates`,
|
||||||
actions: [
|
// actions: [
|
||||||
{
|
// {
|
||||||
label: "Refresh",
|
// label: "Refresh",
|
||||||
variant: "secondary",
|
// variant: "secondary",
|
||||||
onClick: loadTemplates,
|
// onClick: loadTemplates,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: "Create template",
|
// label: "Create template",
|
||||||
onClick: () => {
|
// onClick: () => {
|
||||||
actionError.value = null;
|
// actionError.value = null;
|
||||||
createOpen.value = true;
|
// createOpen.value = true;
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
}));
|
// }));
|
||||||
|
|
||||||
onMounted(loadTemplates);
|
onMounted(loadTemplates);
|
||||||
</script>
|
</script>
|
||||||
@@ -363,6 +363,10 @@ onMounted(loadTemplates);
|
|||||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
||||||
|
|
||||||
<SettingsSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass="">
|
<SettingsSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass="">
|
||||||
|
<template #header-actions>
|
||||||
|
<AppButton size="sm" variant="ghost" @click="loadTemplates">Refresh</AppButton>
|
||||||
|
<AppButton size="sm" @click="createOpen = true; actionError = null">Create template</AppButton>
|
||||||
|
</template>
|
||||||
<AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" />
|
<AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" />
|
||||||
|
|
||||||
<BaseTable
|
<BaseTable
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSke
|
|||||||
import type { ColumnDef } from "@tanstack/vue-table";
|
import type { ColumnDef } from "@tanstack/vue-table";
|
||||||
import { computed, h, onMounted, ref } from "vue";
|
import { computed, h, onMounted, ref } from "vue";
|
||||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
|
||||||
|
|
||||||
type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>;
|
type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>;
|
||||||
type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number];
|
type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number];
|
||||||
@@ -144,25 +143,6 @@ const statusBadgeClass = (status?: string) => {
|
|||||||
return "border-border bg-muted/40 text-foreground/70";
|
return "border-border bg-muted/40 text-foreground/70";
|
||||||
};
|
};
|
||||||
|
|
||||||
useAdminPageHeader(() => ({
|
|
||||||
eyebrow: "Workers",
|
|
||||||
badge: `${rows.value.length} agents connected`,
|
|
||||||
actions: [{
|
|
||||||
label: "Refresh agents",
|
|
||||||
variant: "secondary",
|
|
||||||
onClick: loadAgents,
|
|
||||||
}],
|
|
||||||
}));
|
|
||||||
|
|
||||||
useAdminPageHeader(() => ({
|
|
||||||
eyebrow: "Workers",
|
|
||||||
badge: `${rows.value.length} agents connected`,
|
|
||||||
actions: [{
|
|
||||||
label: "Refresh agents",
|
|
||||||
variant: "secondary",
|
|
||||||
onClick: loadAgents,
|
|
||||||
}],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
|
const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -294,7 +274,10 @@ onMounted(loadAgents);
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
|
||||||
|
|
||||||
<SettingsSectionCard v-else title="Agents" description="Connected workers and runtime health." bodyClass="">
|
<SettingsSectionCard v-else title="Agents" :description="`${rows.length} agents connected`" bodyClass="">
|
||||||
|
<template #header-actions>
|
||||||
|
<AppButton size="sm" variant="ghost" @click="loadAgents">Refresh</AppButton>
|
||||||
|
</template>
|
||||||
<SettingsTableSkeleton v-if="loading" :columns="8" :rows="4" />
|
<SettingsTableSkeleton v-if="loading" :columns="8" :rows="4" />
|
||||||
|
|
||||||
<BaseTable
|
<BaseTable
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { client as rpcClient } from "@/api/rpcclient";
|
import { client as rpcClient } from "@/api/rpcclient";
|
||||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
|
||||||
import AppButton from "@/components/app/AppButton.vue";
|
import AppButton from "@/components/app/AppButton.vue";
|
||||||
import AppInput from "@/components/app/AppInput.vue";
|
import AppInput from "@/components/app/AppInput.vue";
|
||||||
|
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -60,15 +59,6 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useAdminPageHeader(() => ({
|
|
||||||
eyebrow: "Observability",
|
|
||||||
badge: activeJobId.value ? "Live tail attached" : "Awaiting job selection",
|
|
||||||
actions: [{
|
|
||||||
label: "Load logs",
|
|
||||||
variant: "secondary",
|
|
||||||
onClick: loadLogs,
|
|
||||||
}],
|
|
||||||
}));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -241,24 +241,24 @@ const statusBadgeClass = (status?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useAdminPageHeader(() => ({
|
// useAdminPageHeader(() => ({
|
||||||
eyebrow: "Finance",
|
// eyebrow: "Finance",
|
||||||
badge: `${total.value} total payments`,
|
// badge: `${total.value} total payments`,
|
||||||
actions: [
|
// actions: [
|
||||||
{
|
// {
|
||||||
label: "Refresh",
|
// label: "Refresh",
|
||||||
variant: "secondary",
|
// variant: "secondary",
|
||||||
onClick: loadPayments,
|
// onClick: loadPayments,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: "Create payment",
|
// label: "Create payment",
|
||||||
onClick: () => {
|
// onClick: () => {
|
||||||
actionError.value = null;
|
// actionError.value = null;
|
||||||
createOpen.value = true;
|
// createOpen.value = true;
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
}));
|
// }));
|
||||||
|
|
||||||
const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
|
const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -386,6 +386,10 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass="">
|
<SettingsSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass="">
|
||||||
|
<template #header-actions>
|
||||||
|
<AppButton size="sm" variant="ghost" @click="loadPayments">Refresh</AppButton>
|
||||||
|
<AppButton size="sm" @click="createOpen = true">Create payment</AppButton>
|
||||||
|
</template>
|
||||||
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
||||||
|
|
||||||
<BaseTable
|
<BaseTable
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { client as rpcClient } from "@/api/rpcclient";
|
import { client, client as rpcClient } from "@/api/rpcclient";
|
||||||
import AppButton from "@/components/app/AppButton.vue";
|
import AppButton from "@/components/app/AppButton.vue";
|
||||||
import AppDialog from "@/components/app/AppDialog.vue";
|
import AppDialog from "@/components/app/AppDialog.vue";
|
||||||
import AppInput from "@/components/app/AppInput.vue";
|
import AppInput from "@/components/app/AppInput.vue";
|
||||||
@@ -10,6 +10,7 @@ import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
|||||||
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
|
||||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||||
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
import { useAdminPageHeader } from "./components/useAdminPageHeader";
|
||||||
|
import AsyncSelect from "@/components/ui/AsyncSelect.vue";
|
||||||
|
|
||||||
type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>;
|
type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>;
|
||||||
type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number];
|
type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number];
|
||||||
@@ -408,6 +409,10 @@ onMounted(loadUsers);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass="">
|
<SettingsSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass="">
|
||||||
|
<template #header-actions>
|
||||||
|
<AppButton size="sm" variant="ghost" @click="loadUsers">Refresh</AppButton>
|
||||||
|
<AppButton size="sm" @click="createOpen = true; actionError = null">Create user</AppButton>
|
||||||
|
</template>
|
||||||
<AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" />
|
<AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -465,8 +470,8 @@ onMounted(loadUsers);
|
|||||||
<AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" />
|
<AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
<label class="text-sm font-medium text-gray-700">Plan</label>
|
||||||
<AppInput v-model="createForm.planId" placeholder="Optional" />
|
<AsyncSelect v-model="editForm.planId" :loadOptions="() => client.listPlans().then(plans => (plans?.plans || []).map(p => ({ label: p.name!, value: p.id! })))" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,8 +528,12 @@ onMounted(loadUsers);
|
|||||||
<AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" />
|
<AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
<label class="text-sm font-medium text-gray-700">Plan</label>
|
||||||
<AppInput v-model="editForm.planId" placeholder="Optional" />
|
<AsyncSelect v-model="editForm.planId" :loadOptions="() => client.listPlans().then(plans => (plans?.plans || []).map(p => ({ label: p.name!, value: p.id! })))" />
|
||||||
|
<!-- <select v-model="editForm.planId" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||||
|
<option v-for="plan in selectedRow?.availablePlans || []" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
|
||||||
|
</select> -->
|
||||||
|
<!-- <AppInput v-model="editForm.planId" placeholder="Optional" /> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -371,24 +371,24 @@ const columns = computed<ColumnDef<AdminVideoRow>[]>(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useAdminPageHeader(() => ({
|
// useAdminPageHeader(() => ({
|
||||||
eyebrow: "Media",
|
// eyebrow: "Media",
|
||||||
badge: `${total.value} total videos`,
|
// badge: `${total.value} total videos`,
|
||||||
actions: [
|
// actions: [
|
||||||
{
|
// {
|
||||||
label: "Refresh",
|
// label: "Refresh",
|
||||||
variant: "secondary",
|
// variant: "secondary",
|
||||||
onClick: loadVideos,
|
// onClick: loadVideos,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: "Create video",
|
// label: "Create video",
|
||||||
onClick: () => {
|
// onClick: () => {
|
||||||
actionError.value = null;
|
// actionError.value = null;
|
||||||
createOpen.value = true;
|
// createOpen.value = true;
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
}));
|
// }));
|
||||||
|
|
||||||
watch(statusFilter, async () => {
|
watch(statusFilter, async () => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
@@ -437,6 +437,10 @@ onMounted(loadVideos);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass="">
|
<SettingsSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass="">
|
||||||
|
<template #header-actions>
|
||||||
|
<AppButton size="sm" variant="ghost" @click="loadVideos">Refresh</AppButton>
|
||||||
|
<AppButton size="sm" @click="createOpen = true; actionError = null">Create video</AppButton>
|
||||||
|
</template>
|
||||||
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
|
||||||
|
|
||||||
<BaseTable
|
<BaseTable
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ declare module "hono" {
|
|||||||
userId: string;
|
userId: string;
|
||||||
role: string;
|
role: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
nonce: Uint8Array<ArrayBufferLike>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { authenticate } from "@/server/middlewares/authenticate";
|
import { authenticate } from "@/server/middlewares/authenticate";
|
||||||
import { getGrpcMetadataFromContext } from "@/server/services/grpcClient";
|
import { getGrpcMetadataFromContext } from "@/server/services/grpcClient";
|
||||||
import { clientJSON } from "@/shared/secure-json-transformer";
|
import { clientJSON, parse, stringify } from "@/shared/secure-json-transformer";
|
||||||
import { Metadata } from "@grpc/grpc-js";
|
import { Metadata } from "@grpc/grpc-js";
|
||||||
import { exposeTinyRpc, httpServerAdapter } from "@hiogawa/tiny-rpc";
|
import { exposeTinyRpc, httpServerAdapter } from "@hiogawa/tiny-rpc";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { protectedAuthMethods, publicAuthMethods } from "./auth";
|
import { protectedAuthMethods, publicAuthMethods } from "./auth";
|
||||||
import { meMethods } from "./me";
|
import { meMethods } from "./me";
|
||||||
|
import { getContext } from "hono/context-storage";
|
||||||
|
|
||||||
declare module "hono" {
|
declare module "hono" {
|
||||||
interface ContextVariableMap {
|
interface ContextVariableMap {
|
||||||
@@ -29,15 +30,30 @@ export const publicEndpoint = "/rpc-public/*";
|
|||||||
export const pathsForGET: (keyof typeof protectedRoutes)[] = ["health"];
|
export const pathsForGET: (keyof typeof protectedRoutes)[] = ["health"];
|
||||||
|
|
||||||
export function registerRpcRoutes(app: Hono) {
|
export function registerRpcRoutes(app: Hono) {
|
||||||
|
const JSONProcessor: JsonTransformer = {
|
||||||
|
parse: (v) => parse(v, () => getContext()?.req.header()),
|
||||||
|
stringify: (v) =>
|
||||||
|
stringify(v, (headers) => {
|
||||||
|
const ctx = getContext();
|
||||||
|
if (ctx) {
|
||||||
|
Object.entries(headers).forEach(([k, v]) => {
|
||||||
|
ctx.header(k, v);
|
||||||
|
});
|
||||||
|
// ctx.header()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
const protectedHandler = exposeTinyRpc({
|
const protectedHandler = exposeTinyRpc({
|
||||||
routes: protectedRoutes,
|
routes: protectedRoutes,
|
||||||
adapter: httpServerAdapter({ endpoint: "/rpc",JSON:clientJSON }),
|
adapter: httpServerAdapter({ endpoint: "/rpc", JSON: JSONProcessor }),
|
||||||
});
|
});
|
||||||
app.use(publicEndpoint, async (c, next) => {
|
app.use(publicEndpoint, async (c, next) => {
|
||||||
|
|
||||||
const publicHandler = exposeTinyRpc({
|
const publicHandler = exposeTinyRpc({
|
||||||
routes: publicRoutes,
|
routes: publicRoutes,
|
||||||
adapter: httpServerAdapter({ endpoint: "/rpc-public", JSON:clientJSON }),
|
adapter: httpServerAdapter({
|
||||||
|
endpoint: "/rpc-public",
|
||||||
|
JSON: JSONProcessor,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const res = await publicHandler({ request: c.req.raw });
|
const res = await publicHandler({ request: c.req.raw });
|
||||||
if (res) {
|
if (res) {
|
||||||
@@ -46,7 +62,6 @@ export function registerRpcRoutes(app: Hono) {
|
|||||||
return await next();
|
return await next();
|
||||||
});
|
});
|
||||||
app.use(endpoint, authenticate, async (c, next) => {
|
app.use(endpoint, authenticate, async (c, next) => {
|
||||||
|
|
||||||
c.set("grpcMetadata", getGrpcMetadataFromContext());
|
c.set("grpcMetadata", getGrpcMetadataFromContext());
|
||||||
|
|
||||||
const res = await protectedHandler({ request: c.req.raw });
|
const res = await protectedHandler({ request: c.req.raw });
|
||||||
@@ -55,6 +70,4 @@ export function registerRpcRoutes(app: Hono) {
|
|||||||
}
|
}
|
||||||
return await next();
|
return await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import { createApp } from '@/main';
|
|||||||
import { htmlEscape } from '@/server/utils/htmlEscape';
|
import { htmlEscape } from '@/server/utils/htmlEscape';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
|
import nacl from 'tweetnacl';
|
||||||
|
import { toBase64 } from '@/shared/secure-json-transformer';
|
||||||
|
|
||||||
export function registerSSRRoutes(app: Hono) {
|
export function registerSSRRoutes(app: Hono) {
|
||||||
app.get("*", async (c) => {
|
app.get("*", async (c) => {
|
||||||
const nonce = crypto.randomUUID();
|
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
||||||
|
c.set("nonce", nonce);
|
||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const lang = c.get("language");
|
const lang = c.get("language");
|
||||||
const { app: vueApp, router, head, pinia, bodyClass, queryCache } = await createApp(lang);
|
const { app: vueApp, router, head, pinia, bodyClass, queryCache } = await createApp(lang);
|
||||||
@@ -28,7 +31,7 @@ export function registerSSRRoutes(app: Hono) {
|
|||||||
return streamText(c, async (stream) => {
|
return streamText(c, async (stream) => {
|
||||||
c.header("Content-Type", "text/html; charset=utf-8");
|
c.header("Content-Type", "text/html; charset=utf-8");
|
||||||
c.header("Content-Encoding", "Identity");
|
c.header("Content-Encoding", "Identity");
|
||||||
|
c.header("nonce", toBase64(nonce));
|
||||||
const ctx: Record<string, any> = {};
|
const ctx: Record<string, any> = {};
|
||||||
const appStream = renderToWebStream(vueApp, ctx);
|
const appStream = renderToWebStream(vueApp, ctx);
|
||||||
|
|
||||||
@@ -67,7 +70,7 @@ export function registerSSRRoutes(app: Hono) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// App data script
|
// App data script
|
||||||
const appDataScript = `<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape(JSON.stringify(ctx))}</script>`;
|
const appDataScript = `<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${toBase64(nonce)}">${htmlEscape(JSON.stringify(ctx))}</script>`;
|
||||||
await stream.write(appDataScript);
|
await stream.write(appDataScript);
|
||||||
|
|
||||||
// Close HTML
|
// Close HTML
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import nacl from "tweetnacl";
|
import nacl from "tweetnacl";
|
||||||
|
|
||||||
const secureConfig = { kid: 'xUJh4/ADCkL/mZTsxSofIVTgLrTLw2C8h/X8/StUc0E=', publicKeyBase64: 'hvtS8b4RWXkau3B2UXbWhCV1NxS/97DGLfcftf/0TG8=' };
|
const kp = nacl.box.keyPair();
|
||||||
|
|
||||||
|
const uToBase64 = (u8: Uint8Array) => btoa(String.fromCharCode(...u8));
|
||||||
|
const secureConfig = { kid: uToBase64(kp.secretKey), publicKeyBase64: uToBase64(kp.publicKey) };
|
||||||
export type SecureEnvelopeV1 = {
|
export type SecureEnvelopeV1 = {
|
||||||
kid: string;
|
kid: string;
|
||||||
nonce: string; // base64
|
nonce: string; // base64
|
||||||
@@ -15,7 +18,7 @@ export type ServerPublicKeyConfig = {
|
|||||||
publicKeyBase64: string;
|
publicKeyBase64: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toBase64(bytes: Uint8Array): string {
|
export function toBase64(bytes: Uint8Array): string {
|
||||||
if (typeof Buffer !== "undefined") {
|
if (typeof Buffer !== "undefined") {
|
||||||
return Buffer.from(bytes).toString("base64");
|
return Buffer.from(bytes).toString("base64");
|
||||||
}
|
}
|
||||||
@@ -24,7 +27,7 @@ function toBase64(bytes: Uint8Array): string {
|
|||||||
return btoa(s);
|
return btoa(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromBase64(base64: string): Uint8Array {
|
export function fromBase64(base64: string): Uint8Array {
|
||||||
if (typeof Buffer !== "undefined") {
|
if (typeof Buffer !== "undefined") {
|
||||||
return new Uint8Array(Buffer.from(base64, "base64"));
|
return new Uint8Array(Buffer.from(base64, "base64"));
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,7 @@ export function createEncryptedInputTransformer(opts: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function stringify(object: any) {
|
export function stringify(object: any, setHeader?: (headers: Record<string, string>) => void): string {
|
||||||
const clientKeypair = createClientKeypair();
|
const clientKeypair = createClientKeypair();
|
||||||
const payload = superjson.serialize(object);
|
const payload = superjson.serialize(object);
|
||||||
const plaintext = utf8Encode(JSON.stringify(payload));
|
const plaintext = utf8Encode(JSON.stringify(payload));
|
||||||
@@ -108,35 +111,40 @@ export function stringify(object: any) {
|
|||||||
// serverPublicKey,
|
// serverPublicKey,
|
||||||
clientKeypair.secretKey,
|
clientKeypair.secretKey,
|
||||||
);
|
);
|
||||||
|
setHeader?.({
|
||||||
return JSON.stringify({
|
|
||||||
kid: secureConfig.kid,
|
kid: secureConfig.kid,
|
||||||
nonce: toBase64(nonce),
|
nonce: toBase64(nonce),
|
||||||
pk: toBase64(clientKeypair.publicKey),
|
pk: toBase64(clientKeypair.publicKey),
|
||||||
data: toBase64(cipher),
|
|
||||||
});
|
});
|
||||||
|
// return JSON.stringify({
|
||||||
|
// kid: secureConfig.kid,
|
||||||
|
// nonce: toBase64(nonce),
|
||||||
|
// pk: toBase64(clientKeypair.publicKey),
|
||||||
|
// data: toBase64(cipher),
|
||||||
|
// });
|
||||||
|
return toBase64(cipher);
|
||||||
}
|
}
|
||||||
export function parse(d: unknown): unknown {
|
export function parse(d: string, getHeader?: () => Record<string, string>): any {
|
||||||
const object = typeof d === "string" ? JSON.parse(d) : d;
|
// const object = typeof d === "string" ? JSON.parse(d) : d;
|
||||||
if (!isSecureEnvelope(object)) {
|
// if (!isSecureEnvelope(object)) {
|
||||||
// console.log("parse RPC payload:", object);
|
// // console.log("parse RPC payload:", object);
|
||||||
return object;
|
// return object;
|
||||||
}
|
// }
|
||||||
|
const headers = getHeader ? getHeader() : {};
|
||||||
// const serverSecretKey = opts.getSecretKeyByKid(object.kid);
|
// const serverSecretKey = opts.getSecretKeyByKid(object.kid);
|
||||||
// if (!serverSecretKey) {
|
// if (!serverSecretKey) {
|
||||||
// throw new Error(`Unknown secure transformer kid: ${object.kid}`);
|
// throw new Error(`Unknown secure transformer kid: ${object.kid}`);
|
||||||
// }
|
// }
|
||||||
const nonce = fromBase64(object.nonce);
|
const nonce = fromBase64(headers.nonce);
|
||||||
const clientPublicKey = fromBase64(object.pk);
|
const clientPublicKey = fromBase64(headers.pk);
|
||||||
const ciphertext = fromBase64(object.data);
|
const ciphertext = fromBase64(d);
|
||||||
|
|
||||||
const opened = nacl.box.open(
|
const opened = nacl.box.open(
|
||||||
ciphertext,
|
ciphertext,
|
||||||
nonce,
|
nonce,
|
||||||
clientPublicKey,
|
clientPublicKey,
|
||||||
// serverSecretKey
|
// serverSecretKey
|
||||||
fromBase64(secureConfig.kid), // for testing, should be replaced with real secret key retrieval
|
fromBase64(headers.kid), // for testing, should be replaced with real secret key retrieval
|
||||||
);
|
);
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
throw new Error("Failed to decrypt tRPC input payload");
|
throw new Error("Failed to decrypt tRPC input payload");
|
||||||
|
|||||||
6
src/type.d.ts
vendored
6
src/type.d.ts
vendored
@@ -13,11 +13,11 @@ declare module "@httpClientAdapter" {
|
|||||||
url: string;
|
url: string;
|
||||||
pathsForGET?: string[];
|
pathsForGET?: string[];
|
||||||
JSON?: Partial<JsonTransformer>;
|
JSON?: Partial<JsonTransformer>;
|
||||||
headers?: () => Promise<{ Authorization?: undefined; } | { Authorization: string; }>
|
headers?: () => Promise<Record<string, string>>;
|
||||||
}): TinyRpcClientAdapter;
|
}): TinyRpcClientAdapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JsonTransformer {
|
interface JsonTransformer {
|
||||||
parse: (v: string) => any; // TODO: eliminate proto pollution at least on server by default cf. https://github.com/fastify/secure-json-parse
|
parse: (v: string, getHeader?: () => Record<string, string>) => any; // TODO: eliminate proto pollution at least on server by default cf. https://github.com/fastify/secure-json-parse
|
||||||
stringify: (v: any) => string;
|
stringify: (v: any, setHeader?: (headers: Record<string, string>) => void) => string;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user