Compare commits

..

No commits in common. "abec71ce52caef0d0ee0a901852b0f2ba454c8ee" and "ead1cb4f710c6e4a80dd05c8998a630b5179bffe" have entirely different histories.

20 changed files with 67 additions and 1110 deletions

1
.gitignore vendored
View File

@ -305,4 +305,3 @@ Cargo.lock
**/*.key **/*.key
**/*.pem **/*.pem
**/*.zip **/*.zip
**/power.conf

View File

@ -40,12 +40,13 @@ pub fn generate_certificate(
let mut x509_name = X509NameBuilder::new()?; let mut x509_name = X509NameBuilder::new()?;
x509_name.append_entry_by_text("CN", certificate_name)?; x509_name.append_entry_by_text("CN", certificate_name)?;
x509_name.append_entry_by_text("C", "CN")?;
x509_name.append_entry_by_text("ST", "Hebei")?;
x509_name.append_entry_by_text("L", "Shi Jiazhuang")?;
x509_name.append_entry_by_text("O", "archgrid.xyz")?;
let x509_name = x509_name.build(); let x509_name = x509_name.build();
let mut x509_issuer = X509NameBuilder::new()?;
x509_issuer.append_entry_by_text("CN", "JetProfile CA")?;
let x509_issuer = x509_issuer.build();
builder.set_subject_name(&x509_name)?; builder.set_subject_name(&x509_name)?;
builder.set_issuer_name(&x509_issuer)?; builder.set_issuer_name(&x509_name)?;
builder.set_version(version)?; builder.set_version(version)?;
let not_before = Asn1Time::days_from_now(0)?; let not_before = Asn1Time::days_from_now(0)?;

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,6 @@ use axum::{http::StatusCode, response::IntoResponse, routing, Json, Router};
use base64::{engine::general_purpose::STANDARD, Engine as _}; use base64::{engine::general_purpose::STANDARD, Engine as _};
use openssl::{hash::MessageDigest, rsa::Padding, sign::Signer}; use openssl::{hash::MessageDigest, rsa::Padding, sign::Signer};
use serde_json::json; use serde_json::json;
use tracing::{debug, info};
use crate::vo::{self, LicenseRequestForm}; use crate::vo::{self, LicenseRequestForm};
@ -25,7 +24,6 @@ impl LicenseController {
} }
async fn issue_license(Json(request_form): Json<LicenseRequestForm>) -> impl IntoResponse { async fn issue_license(Json(request_form): Json<LicenseRequestForm>) -> impl IntoResponse {
info!(request_form = ?request_form, "Received license request");
let mut license = vo::License::new( let mut license = vo::License::new(
request_form.licensee_name, request_form.licensee_name,
request_form.assignee_name, request_form.assignee_name,
@ -35,14 +33,17 @@ async fn issue_license(Json(request_form): Json<LicenseRequestForm>) -> impl Int
license.add_product(p, request_form.valid_days); license.add_product(p, request_form.valid_days);
} }
let serialized_license = license.serialize(); let serialized_license = license.serialize();
debug!(license = ?license, "License created: {}", serialized_license);
let private_key = crate::certificate::get_private_key(); let private_key = crate::certificate::get_private_key();
let mut signer = Signer::new(MessageDigest::sha1(), private_key).unwrap(); let mut signer = Signer::new(MessageDigest::sha1(), private_key).unwrap();
signer.set_rsa_padding(Padding::PKCS1).unwrap(); signer.set_rsa_padding(Padding::PKCS1).unwrap();
signer.update(serialized_license.as_bytes()).unwrap(); signer.update(serialized_license.as_bytes()).unwrap();
let cert = crate::certificate::get_public_key().to_der().unwrap(); let cert = crate::certificate::get_public_key()
.public_key()
.unwrap()
.public_key_to_der()
.unwrap();
let base64_license = STANDARD.encode(serialized_license); let base64_license = STANDARD.encode(serialized_license);
let base64_signature = STANDARD.encode(signer.sign_to_vec().unwrap()); let base64_signature = STANDARD.encode(signer.sign_to_vec().unwrap());

Binary file not shown.

View File

@ -15,7 +15,6 @@
"@mantine/form": "^7.7.1", "@mantine/form": "^7.7.1",
"@mantine/hooks": "^7.7.1", "@mantine/hooks": "^7.7.1",
"@mantine/modals": "^7.7.1", "@mantine/modals": "^7.7.1",
"@mantine/notifications": "^7.7.1",
"@tabler/icons-react": "^3.1.0", "@tabler/icons-react": "^3.1.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",

View File

@ -6,8 +6,7 @@
gap: var(--mantine-space-lg); gap: var(--mantine-space-lg);
} }
.form-column { .form-column {
flex-shrink: 0; flex-grow: 1;
flex-grow: 0;
} }
.product-column { .product-column {
flex-grow: 5; flex-grow: 5;

View File

@ -13,7 +13,6 @@ function App() {
align="stretch" align="stretch"
justify="flex-start" justify="flex-start"
gap="lg" gap="lg"
w={450}
className={classes["form-column"]} className={classes["form-column"]}
> >
<Steps /> <Steps />

View File

@ -1,6 +1,5 @@
.container { .container {
flex-grow: 1; flex-grow: 1;
overflow-y: hidden;
} }
.license-code-area { .license-code-area {
flex-grow: 1; flex-grow: 1;

View File

@ -1,34 +1,22 @@
import { useLicenseCodeStore } from "@/hooks/use_license_code_store"; import { useLicenseCode } from "@/hooks/use-license-code";
import { ActionIcon, Box, Flex, Group, Paper, Title, Tooltip } from "@mantine/core"; import { ActionIcon, Box, Flex, Group, Paper, Title, Tooltip } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconCopy } from "@tabler/icons-react"; import { IconCopy } from "@tabler/icons-react";
import { isEmpty, not } from "ramda";
import classes from "./LicenseCode.module.css"; import classes from "./LicenseCode.module.css";
export function LicenseCode() { export function LicenseCode() {
const licenseCode = useLicenseCodeStore((state) => state.licenceCode); const [licenseCode] = useLicenseCode();
const copyLicenseCode = async () => {
if (not(isEmpty(licenseCode))) {
await navigator.clipboard.writeText(licenseCode);
notifications.show({
title: "授权码已复制",
color: "green",
});
}
};
return ( return (
<Paper shadow="md" p="lg" className={classes["container"]}> <Paper shadow="md" p="lg" className={classes["container"]}>
<Flex direction="column" justify="flex-start" align="stretch" gap="md" h="100%"> <Flex direction="column" justify="flex-start" align="stretch" gap="md">
<Group justify="space-between"> <Group justify="space-between">
<Title order={4}></Title> <Title order={4}></Title>
<Tooltip label="复制授权码" position="top"> <Tooltip label="复制授权码" position="top">
<ActionIcon size="lg" onClick={copyLicenseCode}> <ActionIcon>
<IconCopy size={16} /> <IconCopy size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
<Box className={classes["license-code-area"]} c="green" fz="xs"> <Box className={classes["license-code-area"]} c="green">
{licenseCode} {licenseCode}
</Box> </Box>
</Flex> </Flex>

View File

@ -1,104 +1,63 @@
import { licenseValidLength } from "@/constants";
import { useLicenseCodeStore } from "@/hooks/use_license_code_store";
import { useProductsStore } from "@/hooks/use_products_store";
import type { LicenseInfoForm } from "@/types";
import { Button, Flex, Group, Paper, Select, TextInput, Title } from "@mantine/core"; import { Button, Flex, Group, Paper, Select, TextInput, Title } from "@mantine/core";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import { IconAt, IconCalendar, IconTag, IconUser } from "@tabler/icons-react"; import { IconAt, IconCalendar, IconTag, IconUser } from "@tabler/icons-react";
import dayjs from "dayjs"; import { pluck } from "ramda";
import { find, isEmpty, pluck, prop, propEq } from "ramda";
import { useCallback } from "react"; const licenseValidLength = [
{ value: 1, label: "一年" },
{ value: 2, label: "两年" },
{ value: 3, label: "三年" },
{ value: 4, label: "四年" },
{ value: 5, label: "五年" },
{ value: 6, label: "六年" },
{ value: 7, label: "七年" },
{ value: 8, label: "八年" },
{ value: 9, label: "九年" },
{ value: 10, label: "十年" },
{ value: 15, label: "十五年" },
{ value: 20, label: "二十年" },
{ value: 25, label: "二十五年" },
{ value: 30, label: "三十年" },
{ value: 35, label: "三十五年" },
{ value: 40, label: "四十年" },
{ value: 45, label: "四十五年" },
{ value: 50, label: "五十年" },
];
export function LicenseForm() { export function LicenseForm() {
const form = useForm<LicenseInfoForm>({
initialValues: {
licenseName: "",
assigneeName: "",
assigneeEmail: "",
validLength: licenseValidLength[0].label,
},
validate: {
licenseName: (value) => (isEmpty(value) ? "授权名称不能为空" : null),
assigneeName: (value) => (isEmpty(value) ? "被授权人不能为空" : null),
},
});
const selectedProducts = useProductsStore((state) => state.selectedProducts);
const updateLicenseCode = useLicenseCodeStore((state) => state.setLicenseCode);
const licenseGenAction = useCallback(
async (formValue: LicenseInfoForm) => {
const license = await generateLicense(formValue, selectedProducts);
updateLicenseCode(license);
},
[selectedProducts, updateLicenseCode]
);
return ( return (
<Paper shadow="md" p="lg"> <Paper shadow="md" p="lg">
<form onSubmit={form.onSubmit(licenseGenAction)}> <form>
<Flex direction="column" gap="md"> <Flex direction="column" gap="md">
<Title order={4}></Title> <Title order={4}></Title>
<TextInput <TextInput
label="授权名称" label="授权名称"
placeholder="请输入授权名称" placeholder="请输入授权名称"
withAsterisk required
leftSection={<IconTag size={16} />} leftSection={<IconTag size={16} />}
{...form.getInputProps("licenseName")}
/> />
<TextInput <TextInput
label="被授权人" label="被授权人"
placeholder="请输入授权人名称" placeholder="请输入授权人名称"
withAsterisk required
leftSection={<IconUser size={16} />} leftSection={<IconUser size={16} />}
{...form.getInputProps("assigneeName")}
/> />
<TextInput <TextInput
label="被授权人Email" label="被授权人Email"
placeholder="请输入授权人Email" placeholder="请输入授权人Email"
leftSection={<IconAt size={16} />} leftSection={<IconAt size={16} />}
{...form.getInputProps("assigneeEmail")}
/> />
<Select <Select
label="授权有效时长" label="授权有效时长"
placeholder="请选择授权有效时长" placeholder="请选择授权有效时长"
data={pluck("label", licenseValidLength)} data={pluck("label", licenseValidLength)}
defaultValue={licenseValidLength[0].label}
leftSection={<IconCalendar size={16} />} leftSection={<IconCalendar size={16} />}
{...form.getInputProps("validLength")}
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button type="submit"></Button> <Button></Button>
</Group> </Group>
</Flex> </Flex>
</form> </form>
</Paper> </Paper>
); );
} }
async function generateLicense(form: LicenseInfoForm, selectedProducts: string[]) {
const validYears = find(propEq(form.validLength, "label"), licenseValidLength)?.value ?? 0;
const now = dayjs();
const validDays = now.add(validYears, "year").diff(now, "day");
if (isEmpty(selectedProducts)) {
notifications.show({
title: "至少需要选择一个产品",
color: "red",
});
return "";
}
const requestBody = {
licenseeName: form.licenseName,
assigneeName: form.assigneeName,
assigneeEmail: form.assigneeEmail,
validDays,
requestProducts: selectedProducts,
};
const response = await fetch("/api/license", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
return prop<"license", { license: string }>("license", data);
}

View File

@ -7,10 +7,3 @@
.title-search { .title-search {
flex-grow: 1; flex-grow: 1;
} }
.product-list-container {
align-content: flex-start;
flex-grow: 1;
overflow-x: hidden;
overflow-y: auto;
min-height: 0;
}

View File

@ -1,100 +1,22 @@
import { useProducts } from "@/hooks/use_products"; import { Flex, Paper, TextInput, Title } from "@mantine/core";
import { useProductsStore } from "@/hooks/use_products_store"; import { IconSearch } from "@tabler/icons-react";
import { ActionIcon, Box, Flex, Paper, Text, TextInput, Title, Tooltip } from "@mantine/core";
import { IconDeselect, IconListCheck, IconSearch, IconX } from "@tabler/icons-react";
import { isEmpty, not } from "ramda";
import { useCallback, useMemo, useState } from "react";
import classes from "./ProductList.module.css"; import classes from "./ProductList.module.css";
export function ProductList() { export function ProductList() {
const [keyword, setKeyword] = useState<string>("");
const [selected, products, selectAll, unselectAll] = useProductsStore((state) => [
state.selectedProducts,
state.products,
state.selectAll,
state.unselectAll,
]);
const clearSearchKeyword = useCallback(() => setKeyword(""), []);
useProducts(keyword);
return ( return (
<Paper shadow="md" p="lg" h="100%"> <Paper shadow="md" p="lg" h="100%">
<Flex direction="column" justify="flex-start" align="stretch" gap="md" h="100%"> <Flex direction="column" justify="flex-start" align="stretch" gap="md">
<Flex direction="row" justify={"space-between"} align={"center"} gap="sm"> <Flex direction="row" justify={"space-between"} align={"center"} gap="sm">
<Title order={4} className={classes["title"]} maw={650}> <Title order={4} className={classes["title"]}>
{not(isEmpty(selected)) && `(已选择 ${selected.length} 个产品)`}
</Title> </Title>
<Tooltip label="取消当前的所有选择">
<ActionIcon size="lg" color="red" onClick={unselectAll} disabled={isEmpty(selected)}>
<IconDeselect size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="选择当前所有">
<ActionIcon size="lg" onClick={selectAll}>
<IconListCheck size={16} />
</ActionIcon>
</Tooltip>
<TextInput <TextInput
placeholder="输入关键词检索产品" placeholder="输入关键词检索产品"
className={classes["title-search"]} className={classes["title-search"]}
value={keyword}
onChange={(event) => setKeyword(event.currentTarget.value)}
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon
variant="transparent"
color="gray"
onClick={clearSearchKeyword}
disabled={isEmpty(keyword)}
>
<IconX size={16} />
</ActionIcon>
}
/> />
</Flex> </Flex>
<Flex
direction="row"
justify="flex-start"
align="flex-start"
wrap="wrap"
gap="sm"
className={classes["product-list-container"]}
>
{products.map((product) => (
<ProductItem key={product.id} id={product.id} name={product.name} />
))}
</Flex>
</Flex> </Flex>
</Paper> </Paper>
); );
} }
interface ProductItemProps {
id: string;
name: string;
}
function ProductItem(props: ProductItemProps) {
const [selectedProducts, append, remove] = useProductsStore((state) => [
state.selectedProducts,
state.append,
state.remove,
]);
const selected = useMemo(() => selectedProducts.includes(props.id), [props.id, selectedProducts]);
const handleSelectAction = useCallback(() => {
if (selected) {
remove(props.id);
} else {
append(props.id);
}
}, [props.id, selected, append, remove]);
return (
<Box px="sm" py="xs" bg={selected ? "green" : "gray"} onClick={handleSelectAction}>
<Text size="xs" c={selected ? "white" : "gray"}>
{props.name}
</Text>
</Box>
);
}

View File

@ -1,12 +0,0 @@
export const licenseValidLength = [
{ value: 1, label: "一年" },
{ value: 2, label: "两年" },
{ value: 3, label: "三年" },
{ value: 4, label: "四年" },
{ value: 5, label: "五年" },
{ value: 6, label: "六年" },
{ value: 7, label: "七年" },
{ value: 8, label: "八年" },
{ value: 9, label: "九年" },
{ value: 10, label: "十年" },
];

View File

@ -0,0 +1,6 @@
import { useState } from "react";
export function useLicenseCode(): [string, (licenseCode: string) => void] {
const [licenseCode, setLicenseCode] = useState<string>("");
return [licenseCode, setLicenseCode];
}

View File

@ -1,16 +1,24 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useProductsStore } from "./use_products_store";
type ProductSearchKeyword = string | undefined | null; type ProductSearchKeyword = string | undefined | null;
export function useProducts(keyword: ProductSearchKeyword) { interface Product {
id: string;
name: string;
}
export function useProducts(keyword: ProductSearchKeyword): Product[] {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => { useEffect(() => {
async function fetchProducts() { async function fetchProducts() {
const response = await fetch(`/api/products?keyword=${keyword}`); const response = await fetch(`/api/products?keyword=${keyword}`);
const data = await response.json(); const data = await response.json();
useProductsStore.setState((state) => (state.products = data)); setProducts(data);
} }
fetchProducts(); fetchProducts();
}, [keyword]); }, [keyword]);
return products;
} }

View File

@ -1,11 +0,0 @@
import { create } from "zustand";
interface LicenseCodeStore {
licenceCode?: string;
setLicenseCode: (code: string) => void;
}
export const useLicenseCodeStore = create<LicenseCodeStore>((set) => ({
licenceCode: undefined,
setLicenseCode: (code) => set({ licenceCode: code }),
}));

View File

@ -1,31 +0,0 @@
import { concat, pluck, uniq } from "ramda";
import { create } from "zustand";
interface Product {
id: string;
name: string;
}
interface ProductsStore {
products: Product[];
selectedProducts: string[];
append: (code: string) => void;
remove: (code: string) => void;
unselectAll: () => void;
selectAll: () => void;
}
export const useProductsStore = create<ProductsStore>((set, get) => ({
products: [],
selectedProducts: [],
append: (code: string) =>
set((state) => ({ selectedProducts: [...state.selectedProducts, code] })),
remove: (code: string) =>
set((state) => ({ selectedProducts: state.selectedProducts.filter((item) => item !== code) })),
unselectAll: () => set({ selectedProducts: [] }),
selectAll: () => {
const products = pluck("id", get().products);
const selectedProducts = uniq(concat(get().selectedProducts, products));
set({ selectedProducts: selectedProducts });
},
}));

View File

@ -1,7 +1,5 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import { Notifications } from "@mantine/notifications";
import "@mantine/notifications/styles.css";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
@ -11,7 +9,6 @@ import { theme } from "./theme";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<MantineProvider defaultColorScheme="dark" theme={theme}> <MantineProvider defaultColorScheme="dark" theme={theme}>
<Notifications />
<App /> <App />
</MantineProvider> </MantineProvider>
</React.StrictMode> </React.StrictMode>

View File

@ -1,6 +0,0 @@
export interface LicenseInfoForm {
licenseName: string;
assigneeName: string;
assigneeEmail: string;
validLength: string;
}