wip: Add more fields for certificate information
- Most of the code is generated by Junie but was reviewed and updated by me. - Also install iso-3166-1 library to provide country lists
This commit is contained in:
parent
b997a5c273
commit
46f31de837
4 changed files with 288 additions and 12 deletions
|
@ -15,10 +15,11 @@
|
||||||
"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",
|
||||||
|
|
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
|
@ -14,6 +14,9 @@ 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
|
||||||
|
@ -1289,6 +1292,9 @@ 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'}
|
||||||
|
@ -3361,6 +3367,8 @@ 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: {}
|
||||||
|
|
|
@ -1,34 +1,140 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, useId, useTemplateRef } from "vue";
|
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 formModel = ref(false);
|
||||||
const formRef = useTemplateRef("form");
|
const formRef = useTemplateRef("form");
|
||||||
|
|
||||||
|
// Domain fields
|
||||||
const domainInputId = useId();
|
const domainInputId = useId();
|
||||||
const domainValue = ref("");
|
const domainValue = ref("");
|
||||||
const primaryDomain = ref("");
|
|
||||||
const domains = ref<string[]>([]);
|
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 = [
|
const domainRules = [
|
||||||
(value: string) => !!value || "A value must be provided to add the domain.",
|
(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.)",
|
(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() {
|
function addDomain() {
|
||||||
if (formRef.value?.errors.some(({id}) => domainInputId === id)) {
|
if (formRef.value?.errors.some(({id}) => domainInputId === id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!primaryDomain.value) {
|
|
||||||
primaryDomain.value = domainValue.value;
|
|
||||||
}
|
|
||||||
domains.value.push(domainValue.value);
|
domains.value.push(domainValue.value);
|
||||||
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);
|
const isSubmitting = ref(false);
|
||||||
async function submitNewCertificate() {
|
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>
|
</script>
|
||||||
|
@ -38,41 +144,132 @@ async function submitNewCertificate() {
|
||||||
ref="form"
|
ref="form"
|
||||||
v-model="formModel"
|
v-model="formModel"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@submit="submitNewCertificate"
|
@submit.prevent="submitNewCertificate"
|
||||||
>
|
>
|
||||||
<h2>New certificate</h2>
|
<h2>New certificate</h2>
|
||||||
<fieldset>
|
|
||||||
|
<fieldset class="mb-4">
|
||||||
<legend>Subject details</legend>
|
<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
|
<v-text-field
|
||||||
:id="domainInputId"
|
:id="domainInputId"
|
||||||
v-model="domainValue"
|
v-model="domainValue"
|
||||||
label="Domain(s)"
|
label="Domain(s)"
|
||||||
:rules="domainRules"
|
:rules="domainRules"
|
||||||
@keydown.enter="addDomain"
|
@keydown.enter.prevent="addDomain"
|
||||||
|
hint="Press Enter to add domain"
|
||||||
|
append-icon="mdi-plus"
|
||||||
|
@click:append="addDomain"
|
||||||
/>
|
/>
|
||||||
<ul v-if="domains.length">
|
<ul v-if="domains.length" class="domain-list">
|
||||||
<li
|
<li
|
||||||
v-for="domain in domains"
|
v-for="domain in domains"
|
||||||
:key="domain"
|
:key="domain"
|
||||||
>
|
>
|
||||||
<span>{{ domain }}</span>
|
<span>{{ domain }}</span>
|
||||||
<v-tooltip
|
<v-tooltip
|
||||||
v-if="primaryDomain === domain"
|
v-if="commonName === domain"
|
||||||
text="This is the primary domain for this certificate. It will be used for the certificate common name."
|
text="This is the primary domain for this certificate. It will be used for the certificate common name."
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-icon
|
<v-icon
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
icon="mdi-start-circle-outline"
|
icon="mdi-star"
|
||||||
|
color="amber"
|
||||||
|
class="ml-2"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</fieldset>
|
</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
|
<v-btn
|
||||||
class="w-100 w-md-auto ml-md-auto"
|
class="w-100 w-md-auto ml-md-auto"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="isSubmitting"
|
||||||
>
|
>
|
||||||
Send certificate request
|
Send certificate request
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
@ -80,5 +277,30 @@ async function submitNewCertificate() {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|
45
frontend/src/components/CountryListItem.vue
Normal file
45
frontend/src/components/CountryListItem.vue
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<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>
|
Loading…
Add table
Add a link
Reference in a new issue