Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: application table, add, remove and update #577

Merged
merged 1 commit into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions agate-ui/src/components/ApplicationDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<template>
<q-dialog v-model="showDialog" persistent @hide="onHide">
<q-card class="dialog-md">
<q-card-section>
<div class="text-h6">{{ t(editMode ? 'application.edit' : 'application.add') }}</div>
</q-card-section>

<q-separator />

<q-card-section>
<q-form>
<q-input
v-model="selected.name"
:label="t('name') + ' *'"
:hint="t('name_hint')"
:disable="editMode"
dense
lazy-rules
:rules="[
(val) => !!val || t('name_required'),
(val) => (val?.trim().length ?? 0) >= 3 || t('name_min_length', { min: 3 }),
]"
/>
<q-input v-model="selected.description" :label="t('description')" dense class="q-mb-md" />
<q-input
v-model="key"
:label="t('application.key') + (editMode ? '' : ' *')"
:hint="t(editMode ? 'application.key_hint_edit' : 'application.key_hint')"
dense
lazy-rules
:rules="
editMode
? []
: [
(val) => !!val || t('application.key_required'),
(val) => (val?.trim().length ?? 0) >= 8 || t('application.key_min_length', { min: 8 }),
]
"
class="q-mb-md"
>
<template v-slot:after>
<q-btn
round
dense
size="sm"
:title="t('application.copy_key')"
flat
icon="content_copy"
@click="copyKeyToClipboard"
/>
<q-btn
round
dense
size="sm"
:title="t('application.generate_key')"
flat
icon="lock_reset"
@click="generateKey"
/>
</template>
</q-input>
<q-input
v-model="selected.redirectURI"
:label="t('application.redirect_uris')"
:hint="t('application.redirect_uris_hint')"
dense
class="q-mb-md"
/>
<q-checkbox v-model="selected.autoApproval" :label="t('application.auto_approval')" dense class="q-mb-xs" />
<div class="text-hint q-mb-md">
{{ t('application.auto_approval_hint') }}
</div>
</q-form>
</q-card-section>

<q-separator />

<q-card-actions align="right" class="bg-grey-3">
<q-btn flat :label="t('cancel')" color="secondary" @click="onCancel" v-close-popup />
<q-btn flat :label="t('save')" :disable="!isValid" color="primary" @click="onSave" />
</q-card-actions>
</q-card>
</q-dialog>
</template>

<script setup lang="ts">
import type { ApplicationDto } from 'src/models/Agate';
import { notifyError, notifyInfo, notifySuccess } from 'src/utils/notify';
import { copyToClipboard } from 'quasar';

const { t } = useI18n();
const applicationStore = useApplicationStore();

interface DialogProps {
modelValue: boolean;
application: ApplicationDto | undefined;
}

const props = defineProps<DialogProps>();
const emit = defineEmits(['update:modelValue', 'saved', 'cancel']);

const showDialog = ref(props.modelValue);
const selected = ref<ApplicationDto>(props.application ?? ({ autoApproval: true } as ApplicationDto));
const editMode = ref(false);
const key = ref('');

const isValid = computed(
() =>
selected.value.name &&
selected.value.name.trim().length >= 3 &&
(editMode || (key.value && key.value.trim().length >= 8)),
);

onMounted(() => {
applicationStore.init();
});

watch(
() => props.modelValue,
(value) => {
showDialog.value = value;
selected.value = props.application ? { ...props.application } : ({ autoApproval: true } as ApplicationDto);
editMode.value = props.application !== undefined;
key.value = '';
},
);

function onHide() {
emit('update:modelValue', false);
}

function onCancel() {
emit('cancel');
}

function onSave() {
if (key.value) {
selected.value.key = key.value;
}
applicationStore
.save(selected.value)
.then(() => {
notifySuccess(t('application.saved'));
emit('saved');
})
.catch(() => {
notifyError(t('application.save_failed'));
})
.finally(() => {
emit('update:modelValue', false);
});
}

function generateKey() {
key.value = applicationStore.generateKey();
}

function copyKeyToClipboard() {
if (key.value) {
copyToClipboard(key.value).then(() => {
notifyInfo(t('application.key_copied'));
});
}
}
</script>
143 changes: 143 additions & 0 deletions agate-ui/src/components/ApplicationsTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<template>
<div>
<q-table :rows="applications" flat row-key="name" :columns="columns" :pagination="initialPagination">
<template v-slot:top-left>
<q-btn size="sm" icon="add" color="primary" :label="t('add')" @click="onAdd" />
</template>
<template v-slot:top-right>
<q-input v-model="filter" debounce="300" :placeholder="t('search')" dense clearable class="q-mr-md">
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</template>
<template v-slot:body="props">
<q-tr :props="props" @mouseover="onOverRow(props.row)" @mouseleave="onLeaveRow(props.row)">
<q-td key="id" :props="props">
<code>{{ props.row.id }}</code>
</q-td>
<q-td key="name" :props="props">
<span class="text-primary">{{ props.row.name }}</span>
<div class="float-right">
<q-btn
rounded
dense
flat
size="sm"
color="secondary"
:icon="toolsVisible[props.row.name] ? 'edit' : 'none'"
:title="t('edit')"
class="q-ml-xs"
@click="onShowEdit(props.row)"
/>
<q-btn
v-if="!props.row.hasDatasource"
rounded
dense
flat
size="sm"
color="secondary"
:title="t('delete')"
:icon="toolsVisible[props.row.name] ? 'delete' : 'none'"
class="q-ml-xs"
@click="onShowDelete(props.row)"
/>
</div>
</q-td>
<q-td key="description" :props="props">
<span>{{ props.row.description }}</span>
</q-td>
<q-td key="applications" :props="props">
<template v-for="app in props.row.applications" :key="app">
<q-badge :label="getApplicationName(app)" class="on-left" />
</template>
</q-td>
</q-tr>
</template>
</q-table>
<confirm-dialog
v-model="showDelete"
:title="t('application.remove')"
:text="t('application.remove_confirm', { name: selected?.name })"
@confirm="onDelete"
/>
<application-dialog v-model="showEdit" :application="selected" @saved="onSaved" />
</div>
</template>

<script setup lang="ts">
import type { ApplicationDto } from 'src/models/Agate';
import ApplicationDialog from 'src/components/ApplicationDialog.vue';
import ConfirmDialog from 'src/components/ConfirmDialog.vue';
import { DefaultAlignment } from 'src/components/models';

const { t } = useI18n();
const applicationStore = useApplicationStore();

const filter = ref('');
const toolsVisible = ref<{ [key: string]: boolean }>({});
const initialPagination = ref({
descending: false,
page: 1,
rowsPerPage: 20,
});
const showEdit = ref(false);
const showDelete = ref(false);
const selected = ref();

const applications = computed(
() =>
applicationStore.applications?.filter((app) =>
filter.value ? app.name.toLowerCase().includes(filter.value.toLowerCase()) : true,
) || [],
);
const columns = computed(() => [
{ name: 'id', label: 'ID', field: 'id', align: DefaultAlignment },
{ name: 'name', label: t('name'), field: 'name', align: DefaultAlignment },
{ name: 'description', label: t('description'), field: 'description', align: DefaultAlignment },
]);

onMounted(() => {
applicationStore.init();
refresh();
});

function refresh() {
applicationStore.init();
}

function onOverRow(row: ApplicationDto) {
toolsVisible.value[row.name] = true;
}

function onLeaveRow(row: ApplicationDto) {
toolsVisible.value[row.name] = false;
}

function onShowEdit(row: ApplicationDto) {
selected.value = row;
showEdit.value = true;
}

function onShowDelete(row: ApplicationDto) {
selected.value = row;
showDelete.value = true;
}

function onDelete() {
applicationStore.remove(selected.value).finally(refresh);
}

function onAdd() {
selected.value = undefined;
showEdit.value = true;
}

function onSaved() {
refresh();
}

function getApplicationName(id: string) {
return applicationStore.applications?.find((app) => app.id === id)?.name;
}
</script>
5 changes: 3 additions & 2 deletions agate-ui/src/components/GroupDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<q-input
v-model="selected.name"
:label="t('name') + ' *'"
:hint="t('name_hint')"
:disable="editMode"
dense
lazy-rules
Expand Down Expand Up @@ -56,14 +57,14 @@ const applicationStore = useApplicationStore();

interface DialogProps {
modelValue: boolean;
group: GroupDto;
group: GroupDto | undefined;
}

const props = defineProps<DialogProps>();
const emit = defineEmits(['update:modelValue', 'saved', 'cancel']);

const showDialog = ref(props.modelValue);
const selected = ref<GroupDto>(props.group);
const selected = ref<GroupDto>(props.group ?? ({} as GroupDto));
const editMode = ref(false);

const applicationOptions = computed(
Expand Down
7 changes: 0 additions & 7 deletions agate-ui/src/components/GroupsTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,6 @@
<q-tr :props="props" @mouseover="onOverRow(props.row)" @mouseleave="onLeaveRow(props.row)">
<q-td key="name" :props="props">
<span class="text-primary">{{ props.row.name }}</span>
<q-icon
v-if="props.row.defaultStorage"
name="check"
size="sm"
class="on-right"
:title="t('default_storage')"
/>
<div class="float-right">
<q-btn
rounded
Expand Down
20 changes: 20 additions & 0 deletions agate-ui/src/i18n/en/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ export default {
main: {
powered_by: 'Powered by',
},
application: {
add: 'Add application',
edit: 'Edit application',
remove: 'Remove application',
remove_confirm: 'Please confirm application removal: {name}',
saved: 'Application saved',
save_failed: 'Failed to save application',
key: 'Key',
copy_key: 'Copy key',
generate_key: 'Generate key',
redirect_uris: 'Redirect URIs',
redirect_uris_hint: "Callback URL to the application's server, required in the OAuth context. Use commas to separate multiple allowed callback URLs.",
key_required: 'Key is required',
key_hint: 'This key is used to authenticate the application with the API.',
key_hint_edit: "Leave blank to not modify application's secret key.",
key_min_length: 'Key must be at least {min} characters',
key_copied: 'Key copied',
auto_approval: 'User approved on sign up',
auto_approval_hint: 'Automatically approve a user who signed up through the application. Otherwise the user will be in "Pending" state, requiring manual approval.',
},
group: {
add: 'Add group',
applications_hint: 'Members of a group get access to the applications associated to this group.',
Expand Down
Loading