Compare commits

..

No commits in common. "46f31de8372ab68bd54d2f6ed8686602c97d3a34" and "b6db17e7d8dcb3589775d89b733da3155e91a245" have entirely different histories.

12 changed files with 84 additions and 545 deletions

View file

@ -15,11 +15,10 @@
"dependencies": { "dependencies": {
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"core-js": "^3.41.0", "core-js": "^3.41.0",
"iso-3166-1": "^2.1.1",
"openapi-fetch": "^0.14.0",
"roboto-fontface": "*", "roboto-fontface": "*",
"vue": "^3.5.13", "vue": "^3.5.13",
"vuetify": "^3.8.1" "vuetify": "^3.8.1",
"openapi-fetch": "^0.14.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.24.0", "@eslint/js": "^9.24.0",

View file

@ -14,9 +14,6 @@ importers:
core-js: core-js:
specifier: ^3.41.0 specifier: ^3.41.0
version: 3.41.0 version: 3.41.0
iso-3166-1:
specifier: ^2.1.1
version: 2.1.1
openapi-fetch: openapi-fetch:
specifier: ^0.14.0 specifier: ^0.14.0
version: 0.14.0 version: 0.14.0
@ -1292,9 +1289,6 @@ packages:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
iso-3166-1@2.1.1:
resolution: {integrity: sha512-RZxXf8cw5Y8LyHZIwIRvKw8sWTIHh2/txBT+ehO0QroesVfnz3JNFFX4i/OC/Yuv2bDIVYrHna5PMvjtpefq5w==}
js-levenshtein@1.1.6: js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3367,8 +3361,6 @@ snapshots:
isexe@3.1.1: {} isexe@3.1.1: {}
iso-3166-1@2.1.1: {}
js-levenshtein@1.1.6: {} js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}

View file

@ -1,10 +1,7 @@
<template> <template>
<v-app> <v-app>
<v-app-bar> <v-app-bar>
<v-app-bar-nav-icon <v-app-bar-nav-icon icon="mdi-home" to="/"/>
icon="mdi-home"
to="/"
/>
</v-app-bar> </v-app-bar>
<v-main> <v-main>
<router-view /> <router-view />

View file

@ -1,306 +0,0 @@
<script setup lang="ts">
import { ref, useId, useTemplateRef } from "vue";
import iso3166 from "iso-3166-1";
import CountryListItem from "./CountryListItem.vue";
// Define interface for a country object
interface Country {
country: string;
alpha2: string;
alpha3: string;
numeric: string;
}
const formModel = ref(false);
const formRef = useTemplateRef("form");
// Domain fields
const domainInputId = useId();
const domainValue = ref("");
const domains = ref<string[]>([]);
// Subject fields
const commonName = ref("");
const emailAddress = ref("");
const organization = ref("");
const organizationalUnit = ref("");
const country = ref("");
const countryName = ref("");
const state = ref("");
const locality = ref("");
// Get user's locale from browser
const userLocale = ref<string>(navigator.language || navigator.languages?.[0] || 'en-US');
const userCountryCode = ref<string>(userLocale.value.split('-')[1]?.toUpperCase() || '');
const userCountry = ref<Country | undefined>(undefined);
// Countries data for autocomplete
const allCountries = iso3166.all() as Country[];
const countries = ref<(Country | { divider: boolean; header: string })[]>([]);
// Prepare countries list with user's country at the top
function prepareCountriesList() {
// Find user's country based on browser locale
userCountry.value = allCountries.find(c => c.alpha2 === userCountryCode.value);
// Create the countries list
countries.value = [];
// Add user's country at the top if found
if (userCountry.value) {
countries.value.push(userCountry.value);
countries.value.push({ divider: true, header: 'All Countries' });
// Add all countries except the user's country to avoid duplicates
countries.value.push(...allCountries.filter(c => c.alpha2 !== userCountryCode.value));
} else {
// Add all countries if user's country not found
countries.value.push(...allCountries);
}
}
// Initialize countries list
prepareCountriesList();
// Certificate settings
const keyLength = ref(4096);
const validityDays = ref(365);
const noExpiry = ref(false);
// Validation rules
const domainRules = [
(value: string) => !!value || "A value must be provided to add the domain.",
(value: string) => new RegExp("^[a-z0-9][a-z0-9.]+$").test(value) || "Invalid domain characters provided. (To use some characters, convert the domain to xn-domain format.)",
]
const emailRules = [
(value: string) => !value || /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value) || "Invalid email format.",
];
const keyLengthRules = [
(value: number) => value >= 3000 || "Key length must be at least 3000 bits.",
(value: number) => value <= 6000 || "Key length must not exceed 6000 bits.",
];
const validityDaysRules = [
(value: number) => !value || value > 0 || "Validity period (in days) must be a positive integer.",
];
function addDomain() {
if (formRef.value?.errors.some(({id}) => domainInputId === id)) {
return;
}
domains.value.push(domainValue.value);
domainValue.value = "";
// Set common name to primary domain if not already set
if (!commonName.value) {
commonName.value = domainValue.value;
}
}
const isSubmitting = ref(false);
async function submitNewCertificate() {
if (!formRef.value?.validate()) {
return;
}
isSubmitting.value = true;
try {
// Extract country code if country is an object
const countryCode = typeof country.value === 'object' && country.value !== null
? (country.value as Country).alpha2
: country.value;
// TODO: Implement certificate request submission
console.log({
type: "STANDALONE_CERTIFICATE",
requestedKeyLength: keyLength.value,
requestedValidityDays: noExpiry.value ? null : validityDays.value,
subject: {
commonName: commonName.value,
emailAddress: emailAddress.value,
organization: organization.value,
organizationalUnit: organizationalUnit.value,
country: countryCode,
state: state.value,
locality: locality.value,
},
extension: {
alternativeDnsNames: domains.value,
}
});
} finally {
isSubmitting.value = false;
}
}
</script>
<template>
<v-form
ref="form"
v-model="formModel"
:disabled="isSubmitting"
@submit.prevent="submitNewCertificate"
>
<h2>New certificate</h2>
<fieldset class="mb-4">
<legend>Subject details</legend>
<v-text-field
v-model="emailAddress"
label="Email Address"
:rules="emailRules"
hint="Optional"
class="mb-2"
/>
<v-text-field
v-model="organization"
label="Organization"
hint="Optional"
class="mb-2"
/>
<v-text-field
v-model="organizationalUnit"
label="Organizational Unit"
hint="Optional"
class="mb-2"
/>
<v-autocomplete
v-model="country"
v-model:search="countryName"
:items="countries"
item-title="country"
item-value="alpha2"
label="Country"
hint="Search and select your country"
class="mb-2"
>
<template #item="slotProps">
<CountryListItem :item="slotProps.item" :props="slotProps.props" />
</template>
</v-autocomplete>
<v-text-field
v-model="state"
label="State/Province"
hint="Optional"
class="mb-2"
/>
<v-text-field
v-model="locality"
label="Locality/City"
hint="Optional"
class="mb-2"
/>
</fieldset>
<fieldset class="mb-4">
<legend>Domain Names</legend>
<v-text-field
:id="domainInputId"
v-model="domainValue"
label="Domain(s)"
:rules="domainRules"
@keydown.enter.prevent="addDomain"
hint="Press Enter to add domain"
append-icon="mdi-plus"
@click:append="addDomain"
/>
<ul v-if="domains.length" class="domain-list">
<li
v-for="domain in domains"
:key="domain"
>
<span>{{ domain }}</span>
<v-tooltip
v-if="commonName === domain"
text="This is the primary domain for this certificate. It will be used for the certificate common name."
>
<template #activator="{ props }">
<v-icon
v-bind="props"
icon="mdi-star"
color="amber"
class="ml-2"
/>
</template>
</v-tooltip>
</li>
</ul>
</fieldset>
<fieldset class="mb-4">
<legend>Certificate Settings</legend>
<v-text-field
v-model.number="keyLength"
type="number"
:rules="keyLengthRules"
label="Key Length (bits)"
hint="Value between 3000 and 6000 bits"
class="mb-2"
/>
<v-text-field
v-model.number="validityDays"
type="number"
:rules="validityDaysRules"
label="Validity Period (days)"
hint="Any positive integer value"
:disabled="noExpiry"
/>
<div class="d-flex align-center">
<v-checkbox
v-model="noExpiry"
label="Certificate without expiry"
class="mr-4"
/>
</div>
</fieldset>
<v-btn
class="w-100 w-md-auto ml-md-auto"
type="submit"
color="primary"
:loading="isSubmitting"
>
Send certificate request
</v-btn>
</v-form>
</template>
<style scoped>
fieldset {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px;
margin-bottom: 16px;
}
legend {
padding: 0 8px;
font-weight: 500;
}
.domain-list {
list-style-type: none;
padding-left: 0;
margin-top: 8px;
}
.domain-list li {
padding: 8px;
margin-bottom: 4px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 4px;
display: flex;
align-items: center;
}
</style>

View file

@ -1,45 +0,0 @@
<script setup lang="ts">
// Define props with proper TypeScript typing
import { computed } from "vue";
interface Country {
country: string;
alpha2: string;
alpha3: string;
numeric: string;
}
interface Divider {
divider: boolean;
header: string;
}
// Define a type that can be either a Country or a Divider
type CountryListItemType = Country | Divider;
// Define props with proper TypeScript typing
const props = defineProps<{
item: { raw: CountryListItemType };
props?: Record<string, unknown>;
}>();
// Helper functions to check the type of the item
const isDivider = computed(() => {
return 'divider' in props.item.raw && props.item.raw.divider;
});
const headerText = computed(() => {
return 'header' in props.item.raw ? props.item.raw.header : "";
});
const countryItem = computed(() => {
return !isDivider.value && !headerText.value && 'country' in props.item.raw ? props.item.raw: undefined;
})
</script>
<template>
<v-divider v-if="isDivider" class="my-2" />
<v-list-subheader v-if="headerText">{{ headerText }}</v-list-subheader>
<v-list-item v-if="countryItem" v-bind="props" :title="countryItem.country" :value="countryItem.alpha2" />
</template>

View file

@ -5,12 +5,8 @@
max-width="900" max-width="900"
> >
<div class="text-center"> <div class="text-center">
<div class="text-body-2 font-weight-light mb-n1"> <div class="text-body-2 font-weight-light mb-n1">Welcome to</div>
Welcome to <h1 class="text-h2 font-weight-bold">home-cert-manager</h1>
</div>
<h1 class="text-h2 font-weight-bold">
home-cert-manager
</h1>
</div> </div>
<div class="py-4" /> <div class="py-4" />
@ -30,9 +26,7 @@
</template> </template>
<template #title> <template #title>
<h2 class="text-h5 font-weight-bold"> <h2 class="text-h5 font-weight-bold">Get started</h2>
Get started
</h2>
</template> </template>
</v-card> </v-card>
</v-col> </v-col>

View file

@ -5,10 +5,7 @@
<v-form v-model="valid"> <v-form v-model="valid">
<v-container> <v-container>
<v-row> <v-row>
<v-col <v-col cols="12" md="4">
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.country" v-model="subject.country"
:rules="subject.countryRules" :rules="subject.countryRules"
@ -16,10 +13,7 @@
required required
/> />
</v-col> </v-col>
<v-col <v-col cols="12" md="4">
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.state" v-model="subject.state"
:counter="10" :counter="10"
@ -29,10 +23,7 @@
/> />
</v-col> </v-col>
<v-col <v-col cols="12" md="4">
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.city" v-model="subject.city"
:rules="subject.cityRules" :rules="subject.cityRules"
@ -42,10 +33,7 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
<v-col <v-col cols="12" md="4">
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.organization" v-model="subject.organization"
:rules="subject.organizationRules" :rules="subject.organizationRules"
@ -53,10 +41,7 @@
required required
/> />
</v-col> </v-col>
<v-col <v-col cols="12" md="4">
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.orgUnit" v-model="subject.orgUnit"
:rules="subject.orgUnitRules" :rules="subject.orgUnitRules"
@ -64,10 +49,7 @@
required required
/> />
</v-col> </v-col>
<v-col <v-col cols="12" md="4">
cols="12"
md="4"
>
<v-text-field <v-text-field
v-model="subject.commonName" v-model="subject.commonName"
:counter="10" :counter="10"
@ -78,48 +60,26 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
<v-col <v-col cols="12" md="4">
cols="12" <v-text-field type="number" v-model="validity.duration" :rules="validity.durationRules" label="Duration for validity in Days"/>
md="4"
>
<v-text-field
v-model="validity.duration"
type="number"
:rules="validity.durationRules"
label="Duration for validity in Days"
/>
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
<v-col <v-col cols="12" md="4">
cols="12" <v-text-field label="Alternative Names" v-model="ui.altName">
md="4" <template v-slot:append>
> <v-btn icon="mdi-plus" color="green" :disabled="!ui.altName" @click="addAltName"/>
<v-text-field
v-model="ui.altName"
label="Alternative Names"
>
<template #append>
<v-btn
icon="mdi-plus"
color="green"
:disabled="!ui.altName"
@click="addAltName"
/>
</template> </template>
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col <v-col cols="12" md="8">
cols="12"
md="8"
>
<v-combobox <v-combobox
v-model="subject.altNames" v-model="subject.altNames"
:items="subject.altNames" :items="subject.altNames"
label="Alternative Names" label="Alternative Names"
multiple multiple
> >
<template #selection="data"> <template v-slot:selection="data">
<v-chip <v-chip
:key="JSON.stringify(data.item)" :key="JSON.stringify(data.item)"
v-bind="data" v-bind="data"
@ -132,14 +92,8 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row>
<v-col <v-col cols="12" md="12">
cols="12" <v-textarea v-model="trustingAuthority" label="Trusting Authority"/>
md="12"
>
<v-textarea
v-model="trustingAuthority"
label="Trusting Authority"
/>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@ -147,12 +101,7 @@
</v-card-item> </v-card-item>
<v-card-actions> <v-card-actions>
<v-col class="text-right"> <v-col class="text-right">
<v-btn <v-btn :disabled="!valid" @click="requestCertificate" text="Request certificate" prepend-icon="mdi-certificate"/>
:disabled="!valid"
text="Request certificate"
prepend-icon="mdi-certificate"
@click="requestCertificate"
/>
</v-col> </v-col>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
@ -160,7 +109,7 @@
<script lang="ts"> <script lang="ts">
import type { Validated } from "@/types/util"; import type { Validated } from "@/types/util";
import type { Certificate, Subject } from "@/types/certificate"; import type { Certificate, Subject, Validity } from "@/types/certificate";
const requiredValidation = (fieldName: string) => { const requiredValidation = (fieldName: string) => {
return (val: string) => { return (val: string) => {

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { fetchClient, type Schemas } from "@/plugins/client.ts"; import { fetchClient, type Schemas } from "@/plugins/client.ts";
import { useRouter } from "vue-router";
const certificates = ref<Schemas["Certificate"][]>([]); const certificates = ref<Schemas["Certificate"][]>([]);
@ -14,98 +13,35 @@ onMounted(() => {
getCertificates(); getCertificates();
}); });
const router = useRouter();
function navigateToNew() {
router.push("/certificates/new");
}
function navigateToImport() {
router.push("/certificates/import");
}
</script> </script>
<template> <template>
<v-container> <main>
<article> <h2>Currently known certificates</h2>
<h2>Currently known certificates</h2> <section id="no-certs-found" v-if="certificates.length === 0">
<section <v-row>
v-if="certificates.length === 0" <v-col cols="12" class="text-center">
id="no-certs-found" <h3>There seems to be nothing in here.</h3>
> <p>Why don't you start with one of the following options.</p>
<v-row> <v-row>
<v-col <v-col cols="12" md="6">
cols="12" <v-btn primary>Request new certificate</v-btn>
class="text-center pt-4" </v-col>
> <v-col cols="12" md="6">
<p class="font-weight-bold"> <v-btn>Import an existing certificate</v-btn>
There seems to be nothing in here. </v-col>
</p> </v-row>
<p>Why don't you start with one of the following options.</p> </v-col>
<v-row class="mt-4"> </v-row>
<v-col </section>
cols="12" <section aria-label="Bekannte Zertifikate">
md="6" <ul v-if="certificates.length > 0">
> <li v-for="cert in certificates" :key="cert.fingerprint">
<v-btn <p>{{ cert.fingerprint }}</p>
color="primary" </li>
@click="navigateToNew" </ul>
> </section>
Request new certificate </main>
</v-btn>
</v-col>
<v-col
cols="12"
md="6"
>
<v-btn
color="primary"
@click="navigateToImport"
>
Import an existing certificate
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
</section>
<section
v-if="certificates.length > 0"
id="known-certificates"
>
<ul>
<li
v-for="cert in certificates"
:key="cert.fingerprint"
>
<p>{{ cert.fingerprint }}</p>
</li>
</ul>
<v-row>
<v-col
cols="12"
md="6"
>
<v-btn @click="navigateToNew">
Request a new certificate
</v-btn>
</v-col>
<v-col
cols="12"
md="6"
>
<v-btn
color="primary"
@click="navigateToImport"
>
Import an existing certificate
</v-btn>
</v-col>
</v-row>
</section>
</article>
</v-container>
</template> </template>
<style scoped> <style scoped>

View file

@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import CertificateEditor from "@/components/CertificateEditor.vue";
</script> </script>
<template> <template>
<v-container> <h2>New certificate</h2>
<CertificateEditor />
</v-container>
</template> </template>
<style scoped> <style scoped>

View file

@ -13,7 +13,9 @@ export interface Validity {
to: Date; to: Date;
} }
export type Extensions = object; export interface Extensions {
}
export interface Certificate { export interface Certificate {
issuer: Subject; issuer: Subject;

27
frontend/typed-router.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/cert-request': RouteRecordInfo<'/cert-request', '/cert-request', Record<never, never>, Record<never, never>>,
'/certificates/': RouteRecordInfo<'/certificates/', '/certificates', Record<never, never>, Record<never, never>>,
'/certificates/[fingerprint]': RouteRecordInfo<'/certificates/[fingerprint]', '/certificates/:fingerprint', { fingerprint: ParamValue<true> }, { fingerprint: ParamValue<false> }>,
'/certificates/new': RouteRecordInfo<'/certificates/new', '/certificates/new', Record<never, never>, Record<never, never>>,
}
}

View file

@ -9,9 +9,7 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
VueRouter({ VueRouter(),
dts: './src/generated/typed-routes.d.ts'
}),
Vue({ Vue({
template: { transformAssetUrls }, template: { transformAssetUrls },
}), }),
@ -21,9 +19,7 @@ export default defineConfig({
configFile: 'src/styles/settings.scss', configFile: 'src/styles/settings.scss',
}, },
}), }),
Components({ Components(),
dts: './src/generated/components.d.ts',
}),
ViteFonts({ ViteFonts({
google: { google: {
families: [ { families: [ {