Merge branch 'fix/table-persons' into develop
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s

fix: Enhance dataset editing by ensuring user authentication and ownership checks across multiple controllers
This commit is contained in:
Kaimbacher 2025-11-13 11:30:58 +01:00
commit 6457d233e7
10 changed files with 288 additions and 106 deletions

View file

@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- run: echo "The ${{ github.repository }} repository has been cloned to the runner." - run: echo "The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "The workflow is now ready to test your code on the runner." - run: echo "The workflow is now ready to test your code on the runner."
- name: List files in the repository: - name: List files in the repository
run: | run: |
ls ${{ github.workspace }} ls ${{ github.workspace }}
- run: echo "This job's status is ${{ job.status }}." - run: echo "This job's status is ${{ job.status }}."

View file

@ -76,23 +76,24 @@ export default class MailSettingsController {
public async sendTestMail({ response, auth }: HttpContext) { public async sendTestMail({ response, auth }: HttpContext) {
const user = auth.user!; const user = auth.user!;
const userEmail = user.email; const userEmail = user.email;
// let mailManager = await app.container.make('mail.manager'); // let mailManager = await app.container.make('mail.manager');
// let iwas = mailManager.use(); // let iwas = mailManager.use();
// let test = mail.config.mailers.smtp(); // let test = mail.config.mailers.smtp();
if (!userEmail) { if (!userEmail) {
return response.badRequest({ message: 'User email is not set. Please update your profile.' }); return response.badRequest({ message: 'User email is not set. Please update your profile.' });
} }
try { try {
await mail.send((message) => { await mail.send(
message (message) => {
// .from(Config.get('mail.from.address')) message
.from('tethys@geosphere.at') // .from(Config.get('mail.from.address'))
.to(userEmail) .from('tethys@geosphere.at')
.subject('Test Email') .to(userEmail)
.html('<p>If you received this email, the email configuration seems to be correct.</p>'); .subject('Test Email')
}); .html('<p>If you received this email, the email configuration seems to be correct.</p>');
});
return response.json({ success: true, message: 'Test email sent successfully' }); return response.json({ success: true, message: 'Test email sent successfully' });
// return response.flash('Test email sent successfully!', 'message').redirect().back(); // return response.flash('Test email sent successfully!', 'message').redirect().back();

View file

@ -188,10 +188,16 @@ export default class DatasetsController {
} }
} }
public async approve({ request, inertia, response }: HttpContext) { public async approve({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// $dataset = Dataset::with('user:id,login')->findOrFail($id); // $dataset = Dataset::with('user:id,login')->findOrFail($id);
const dataset = await Dataset.findOrFail(id); const dataset = await Dataset.query().where('id', id).where('editor_id', user.id).firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer']; const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) { if (!validStates.includes(dataset.server_state)) {
@ -217,7 +223,7 @@ export default class DatasetsController {
}); });
} }
public async approveUpdate({ request, response }: HttpContext) { public async approveUpdate({ request, response, auth }: HttpContext) {
const approveDatasetSchema = vine.object({ const approveDatasetSchema = vine.object({
reviewer_id: vine.number(), reviewer_id: vine.number(),
}); });
@ -230,7 +236,11 @@ export default class DatasetsController {
throw error; throw error;
} }
const id = request.param('id'); const id = request.param('id');
const dataset = await Dataset.findOrFail(id); const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query().where('id', id).where('editor_id', user.id).firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer']; const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) { if (!validStates.includes(dataset.server_state)) {
@ -261,10 +271,15 @@ export default class DatasetsController {
} }
} }
public async reject({ request, inertia, response }: HttpContext) { public async reject({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('id', id) .where('id', id)
.where('editor_id', user.id) // Ensure the user is the editor of the dataset
// .preload('titles') // .preload('titles')
// .preload('descriptions') // .preload('descriptions')
.preload('user', (builder) => { .preload('user', (builder) => {
@ -291,10 +306,15 @@ export default class DatasetsController {
public async rejectUpdate({ request, response, auth }: HttpContext) { public async rejectUpdate({ request, response, auth }: HttpContext) {
const authUser = auth.user!; const authUser = auth.user!;
if (!authUser) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const id = request.param('id'); const id = request.param('id');
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('id', id) .where('id', id)
.where('editor_id', authUser.id) // Ensure the user is the editor of the dataset
.preload('user', (builder) => { .preload('user', (builder) => {
builder.select('id', 'login', 'email'); builder.select('id', 'login', 'email');
}) })
@ -377,9 +397,14 @@ export default class DatasetsController {
public async publish({ request, inertia, response, auth }: HttpContext) { public async publish({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('id', id) .where('id', id)
.where('editor_id', user.id) // Ensure the user is the editor of the dataset
.preload('titles') .preload('titles')
.preload('authors') .preload('authors')
// .preload('persons', (builder) => { // .preload('persons', (builder) => {
@ -408,7 +433,7 @@ export default class DatasetsController {
}); });
} }
public async publishUpdate({ request, response }: HttpContext) { public async publishUpdate({ request, response, auth }: HttpContext) {
const publishDatasetSchema = vine.object({ const publishDatasetSchema = vine.object({
publisher_name: vine.string().trim(), publisher_name: vine.string().trim(),
}); });
@ -420,7 +445,12 @@ export default class DatasetsController {
throw error; throw error;
} }
const id = request.param('id'); const id = request.param('id');
const dataset = await Dataset.findOrFail(id); const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query().where('id', id).where('editor_id', user.id).firstOrFail();
// let test = await Dataset.getMax('publish_id'); // let test = await Dataset.getMax('publish_id');
// const maxPublishId = await Database.from('documents').max('publish_id as max_publish_id').first(); // const maxPublishId = await Database.from('documents').max('publish_id as max_publish_id').first();
@ -446,10 +476,16 @@ export default class DatasetsController {
} }
} }
public async rejectToReviewer({ request, inertia, response }: HttpContext) { public async rejectToReviewer({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('id', id) .where('id', id)
.where('editor_id', user.id) // Ensure the user is the editor of the dataset
.preload('reviewer', (builder) => { .preload('reviewer', (builder) => {
builder.select('id', 'login', 'email'); builder.select('id', 'login', 'email');
}) })
@ -475,9 +511,14 @@ export default class DatasetsController {
public async rejectToReviewerUpdate({ request, response, auth }: HttpContext) { public async rejectToReviewerUpdate({ request, response, auth }: HttpContext) {
const authUser = auth.user!; const authUser = auth.user!;
if (!authUser) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const id = request.param('id'); const id = request.param('id');
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('id', id) .where('id', id)
.where('editor_id', authUser.id) // Ensure the user is the editor of the dataset
.preload('reviewer', (builder) => { .preload('reviewer', (builder) => {
builder.select('id', 'login', 'email'); builder.select('id', 'login', 'email');
}) })
@ -558,10 +599,16 @@ export default class DatasetsController {
.toRoute('editor.dataset.list'); .toRoute('editor.dataset.list');
} }
public async doiCreate({ request, inertia }: HttpContext) { public async doiCreate({ request, inertia, auth, response }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('id', id) .where('id', id)
.where('editor_id', user.id) // Ensure the user is the editor of the dataset
.preload('titles') .preload('titles')
.preload('descriptions') .preload('descriptions')
// .preload('identifier') // .preload('identifier')
@ -572,11 +619,18 @@ export default class DatasetsController {
}); });
} }
public async doiStore({ request, response }: HttpContext) { public async doiStore({ request, response, auth }: HttpContext) {
const dataId = request.param('publish_id'); const dataId = request.param('publish_id');
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Load dataset with minimal required relationships // Load dataset with minimal required relationships
const dataset = await Dataset.query().where('publish_id', dataId).firstOrFail(); const dataset = await Dataset.query()
.where('editor_id', user.id) // Ensure the user is the editor of the dataset
.where('publish_id', dataId)
.firstOrFail();
const prefix = process.env.DATACITE_PREFIX || ''; const prefix = process.env.DATACITE_PREFIX || '';
const base_domain = process.env.BASE_DOMAIN || ''; const base_domain = process.env.BASE_DOMAIN || '';
@ -658,9 +712,17 @@ export default class DatasetsController {
public async show({}: HttpContext) {} public async show({}: HttpContext) {}
public async edit({ request, inertia, response }: HttpContext) { public async edit({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const datasetQuery = Dataset.query().where('id', id);
// Check if user is authenticated
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Prefilter by both id AND editor_id to ensure user has permission to edit
const datasetQuery = Dataset.query().where('id', id).where('editor_id', user.id);
datasetQuery datasetQuery
.preload('titles', (query) => query.orderBy('id', 'asc')) .preload('titles', (query) => query.orderBy('id', 'asc'))
.preload('descriptions', (query) => query.orderBy('id', 'asc')) .preload('descriptions', (query) => query.orderBy('id', 'asc'))
@ -677,6 +739,7 @@ export default class DatasetsController {
query.orderBy('sort_order', 'asc'); // Sort by sort_order column query.orderBy('sort_order', 'asc'); // Sort by sort_order column
}); });
// This will throw 404 if editor_id does not match logged in user
const dataset = await datasetQuery.firstOrFail(); const dataset = await datasetQuery.firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer']; const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) { if (!validStates.includes(dataset.server_state)) {
@ -750,11 +813,16 @@ export default class DatasetsController {
}); });
} }
public async update({ request, response, session }: HttpContext) { public async update({ request, response, session, auth }: HttpContext) {
// Get the dataset id from the route parameter // Get the dataset id from the route parameter
const datasetId = request.param('id'); const datasetId = request.param('id');
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Retrieve the dataset and load its existing files // Retrieve the dataset and load its existing files
const dataset = await Dataset.findOrFail(datasetId); const dataset = await Dataset.query().where('id', datasetId).where('editor_id', user.id).firstOrFail();
await dataset.load('files'); await dataset.load('files');
let trx: TransactionClientContract | null = null; let trx: TransactionClientContract | null = null;
@ -763,7 +831,7 @@ export default class DatasetsController {
trx = await db.transaction(); trx = await db.transaction();
// const user = (await User.find(auth.user?.id)) as User; // const user = (await User.find(auth.user?.id)) as User;
// await this.createDatasetAndAssociations(user, request, trx); // await this.createDatasetAndAssociations(user, request, trx);
const dataset = await Dataset.findOrFail(datasetId); // const dataset = await Dataset.findOrFail(datasetId);
// save the licenses // save the licenses
const licenses: number[] = request.input('licenses', []); const licenses: number[] = request.input('licenses', []);
@ -949,10 +1017,15 @@ export default class DatasetsController {
} }
} }
public async categorize({ inertia, request, response }: HttpContext) { public async categorize({ inertia, request, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
// Check if user is authenticated
const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Preload dataset and its "collections" relation // Preload dataset and its "collections" relation
const dataset = await Dataset.query().where('id', id).preload('collections').firstOrFail(); const dataset = await Dataset.query().where('id', id).where('editor_id', user.id).preload('collections').firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer']; const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) { if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!'); // session.flash('errors', 'Invalid server state!');
@ -980,10 +1053,15 @@ export default class DatasetsController {
}); });
} }
public async categorizeUpdate({ request, response, session }: HttpContext) { public async categorizeUpdate({ request, response, session, auth }: HttpContext) {
// Get the dataset id from the route parameter // Get the dataset id from the route parameter
const id = request.param('id'); const id = request.param('id');
const dataset = await Dataset.query().preload('files').where('id', id).firstOrFail(); const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Retrieve the dataset and load its existing files
const dataset = await Dataset.query().preload('files').where('id', id).where('editor_id', user.id).firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer']; const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) { if (!validStates.includes(dataset.server_state)) {
@ -1188,7 +1266,7 @@ export default class DatasetsController {
} }
// return cache.getDomDocument(); // return cache.getDomDocument();
const xmlDocument : XMLBuilder | null = await serializer.toXmlDocument(); const xmlDocument: XMLBuilder | null = await serializer.toXmlDocument();
return xmlDocument; return xmlDocument;
} }
} }

View file

@ -824,13 +824,20 @@ export default class DatasetController {
}; };
// public async release({ params, view }) { // public async release({ params, view }) {
public async release({ request, inertia, response }: HttpContext) { public async release({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query() const dataset = await Dataset.query()
.preload('user', (builder) => { .preload('user', (builder) => {
builder.select('id', 'login'); builder.select('id', 'login');
}) })
.where('account_id', user.id) // Only fetch if user owns it
.where('id', id) .where('id', id)
.firstOrFail(); .firstOrFail();
@ -851,9 +858,20 @@ export default class DatasetController {
}); });
} }
public async releaseUpdate({ request, response }: HttpContext) { public async releaseUpdate({ request, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const dataset = await Dataset.query().preload('files').where('id', id).firstOrFail(); const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
const dataset = await Dataset.query()
.preload('files')
.where('id', id)
.where('account_id', user.id) // Only fetch if user owns it
.firstOrFail();
const validStates = ['inprogress', 'rejected_editor']; const validStates = ['inprogress', 'rejected_editor'];
if (!validStates.includes(dataset.server_state)) { if (!validStates.includes(dataset.server_state)) {
@ -933,7 +951,15 @@ export default class DatasetController {
public async edit({ request, inertia, response, auth }: HttpContext) { public async edit({ request, inertia, response, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const datasetQuery = Dataset.query().where('id', id); const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Prefilter by both id AND account_id
const datasetQuery = Dataset.query().where('id', id).where('account_id', user.id); // Only fetch if user owns it
datasetQuery datasetQuery
.preload('titles', (query) => query.orderBy('id', 'asc')) .preload('titles', (query) => query.orderBy('id', 'asc'))
.preload('descriptions', (query) => query.orderBy('id', 'asc')) .preload('descriptions', (query) => query.orderBy('id', 'asc'))
@ -949,8 +975,9 @@ export default class DatasetController {
.preload('files', (query) => { .preload('files', (query) => {
query.orderBy('sort_order', 'asc'); // Sort by sort_order column query.orderBy('sort_order', 'asc'); // Sort by sort_order column
}); });
// This will throw 404 if dataset doesn't exist OR user doesn't own it
const dataset = await datasetQuery.firstOrFail(); const dataset = await datasetQuery.firstOrFail();
const validStates = ['inprogress', 'rejected_editor']; const validStates = ['inprogress', 'rejected_editor'];
if (!validStates.includes(dataset.server_state)) { if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!'); // session.flash('errors', 'Invalid server state!');
@ -1014,11 +1041,30 @@ export default class DatasetController {
}); });
} }
public async update({ request, response, session }: HttpContext) { public async update({ request, response, session, auth }: HttpContext) {
// Get the dataset id from the route parameter // Get the dataset id from the route parameter
const datasetId = request.param('id'); const datasetId = request.param('id');
// Retrieve the dataset and load its existing files const user = auth.user;
const dataset = await Dataset.findOrFail(datasetId);
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to update a dataset.', 'error').redirect().toRoute('app.login.show');
}
// Prefilter by both id AND account_id
const dataset = await Dataset.query()
.where('id', datasetId)
.where('account_id', user.id) // Only fetch if user owns it
.firstOrFail();
// // Check if the authenticated user is the owner of the dataset
// if (dataset.account_id !== user.id) {
// return response
// .flash(`Unauthorized access. You are not the owner of dataset with id ${id}.`, 'error')
// .redirect()
// .toRoute('dataset.list');
// }
await dataset.load('files'); await dataset.load('files');
// Accumulate the size of the already related files // Accumulate the size of the already related files
// const preExistingFileSize = dataset.files.reduce((acc, file) => acc + file.fileSize, 0); // const preExistingFileSize = dataset.files.reduce((acc, file) => acc + file.fileSize, 0);
@ -1442,16 +1488,26 @@ export default class DatasetController {
} }
} }
public async delete({ request, inertia, response, session }: HttpContext) { public async delete({ request, inertia, response, session, auth }: HttpContext) {
const id = request.param('id'); const id = request.param('id');
const user = auth.user;
// Check if user is authenticated
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
try { try {
// This will throw 404 if dataset doesn't exist OR user doesn't own it
const dataset = await Dataset.query() const dataset = await Dataset.query()
.preload('user', (builder) => { .preload('user', (builder) => {
builder.select('id', 'login'); builder.select('id', 'login');
}) })
.where('id', id) .where('id', id)
.where('account_id', user.id) // Only fetch if user owns it
.preload('files') .preload('files')
.firstOrFail(); .firstOrFail();
const validStates = ['inprogress', 'rejected_editor']; const validStates = ['inprogress', 'rejected_editor'];
if (!validStates.includes(dataset.server_state)) { if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!'); // session.flash('errors', 'Invalid server state!');
@ -1476,9 +1532,27 @@ export default class DatasetController {
} }
} }
public async deleteUpdate({ params, session, response }: HttpContext) { public async deleteUpdate({ params, session, response, auth }: HttpContext) {
try { try {
const dataset = await Dataset.query().where('id', params.id).preload('files').firstOrFail(); const user = auth.user;
if (!user) {
return response.flash('You must be logged in to edit a dataset.', 'error').redirect().toRoute('app.login.show');
}
// This will throw 404 if dataset doesn't exist OR user doesn't own it
const dataset = await Dataset.query()
.where('id', params.id)
.where('account_id', user.id) // Only fetch if user owns it
.preload('files')
.firstOrFail();
// // Check if the authenticated user is the owner of the dataset
// if (dataset.account_id !== user.id) {
// return response
// .flash(`Unauthorized access. You are not the owner of dataset with id ${params.id}.`, 'error')
// .redirect()
// .toRoute('dataset.list');
// }
const validStates = ['inprogress', 'rejected_editor']; const validStates = ['inprogress', 'rejected_editor'];
if (validStates.includes(dataset.server_state)) { if (validStates.includes(dataset.server_state)) {

View file

@ -16,7 +16,7 @@ const mailConfig = defineConfig({
host: env.get('SMTP_HOST', ''), host: env.get('SMTP_HOST', ''),
port: env.get('SMTP_PORT'), port: env.get('SMTP_PORT'),
secure: false, secure: false,
// ignoreTLS: true, ignoreTLS: true,
requireTLS: false, requireTLS: false,
/** /**

View file

@ -67,7 +67,7 @@ const submit = (e) => {
<BaseIcon v-if="icon" :path="icon" class="mr-3" /> <BaseIcon v-if="icon" :path="icon" class="mr-3" />
{{ title }} {{ title }}
</div> </div>
<button v-if="showHeaderIcon" class="flex items-center py-3 px-4 justify-center ring-blue-700 focus:ring" @click="headerIconClick"> <button v-if="showHeaderIcon" class="flex items-center py-3 px-4 justify-center ring-blue-700 focus:ring" @click.stop="headerIconClick">
<BaseIcon :path="computedHeaderIcon" /> <BaseIcon :path="computedHeaderIcon" />
</button> </button>
</header> </header>

View file

@ -87,8 +87,10 @@ import BaseIcon from '@/Components/BaseIcon.vue';
import { MapOptions } from './MapOptions'; import { MapOptions } from './MapOptions';
import { LayerOptions, LayerMap } from './LayerOptions'; import { LayerOptions, LayerMap } from './LayerOptions';
import { MapService } from '@/Stores/map.service'; import { MapService } from '@/Stores/map.service';
import { ZoomControlComponent } from './zoom.component.vue'; // import ZoomControlComponent from '@/Components/Map/zoom.component.vue';
import { DrawControlComponent } from './draw.component.vue'; // import DrawControlComponent from '@/Components/Map/draw.component.vue';
import ZoomControlComponent from './zoom.component.vue';
import DrawControlComponent from './draw.component.vue';
import { Coverage } from '@/Dataset'; import { Coverage } from '@/Dataset';
import { canvas } from 'leaflet/src/layer/vector/Canvas'; import { canvas } from 'leaflet/src/layer/vector/Canvas';
import { svg } from 'leaflet/src/layer/vector/SVG'; import { svg } from 'leaflet/src/layer/vector/SVG';
@ -137,7 +139,7 @@ const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://o
BaseIcon, BaseIcon,
}, },
}) })
export class MapComponent extends Vue { export default class MapComponent extends Vue {
@Prop() @Prop()
public mapId: string; public mapId: string;
@ -301,7 +303,7 @@ export class MapComponent extends Vue {
} }
} }
} }
export default MapComponent; // export default MapComponent;
</script> </script>
<style scoped> <style scoped>

View file

@ -46,8 +46,8 @@ const dragEnabled = ref(props.canReorder);
// Name type options // Name type options
const nameTypeOptions = { const nameTypeOptions = {
'Personal': 'Personal', Personal: 'Personal',
'Organizational': 'Org' Organizational: 'Org',
}; };
// Computed properties // Computed properties
@ -111,9 +111,10 @@ const removeAuthor = (index: number) => {
const actualIndex = perPage.value * currentPage.value + index; const actualIndex = perPage.value * currentPage.value + index;
const person = items.value[actualIndex]; const person = items.value[actualIndex];
const displayName = person.name_type === 'Organizational' const displayName =
? person.last_name || person.email person.name_type === 'Organizational'
: `${person.first_name || ''} ${person.last_name || person.email}`.trim(); ? person.last_name || person.email
: `${person.first_name || ''} ${person.last_name || person.email}`.trim();
if (confirm(`Are you sure you want to remove ${displayName}?`)) { if (confirm(`Are you sure you want to remove ${displayName}?`)) {
items.value.splice(actualIndex, 1); items.value.splice(actualIndex, 1);
@ -128,12 +129,12 @@ const removeAuthor = (index: number) => {
const updatePerson = (index: number, field: keyof Person, value: any) => { const updatePerson = (index: number, field: keyof Person, value: any) => {
const actualIndex = perPage.value * currentPage.value + index; const actualIndex = perPage.value * currentPage.value + index;
const person = items.value[actualIndex]; const person = items.value[actualIndex];
// Handle name_type change - clear first_name if switching to Organizational // Handle name_type change - clear first_name if switching to Organizational
if (field === 'name_type' && value === 'Organizational') { if (field === 'name_type' && value === 'Organizational') {
person.first_name = ''; person.first_name = '';
} }
(person as any)[field] = value; (person as any)[field] = value;
emit('person-updated', actualIndex, person); emit('person-updated', actualIndex, person);
}; };
@ -178,7 +179,10 @@ const perPageOptions = [
<template> <template>
<div class="card"> <div class="card">
<!-- Table Controls --> <!-- Table Controls -->
<div v-if="hasMultiplePages" class="flex justify-between items-center px-4 py-2.5 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50"> <div
v-if="hasMultiplePages"
class="flex justify-between items-center px-4 py-2.5 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
{{ currentPage * perPage + 1 }}-{{ Math.min((currentPage + 1) * perPage, items.length) }} of {{ items.length }} {{ currentPage * perPage + 1 }}-{{ Math.min((currentPage + 1) * perPage, items.length) }} of {{ items.length }}
@ -204,10 +208,18 @@ const perPageOptions = [
<th scope="col" class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 w-10">#</th> <th scope="col" class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 w-10">#</th>
<th class="text-left px-2 py-2 text-[10px] font-semibold text-gray-600 dark:text-gray-300 w-40">Type</th> <th class="text-left px-2 py-2 text-[10px] font-semibold text-gray-600 dark:text-gray-300 w-40">Type</th>
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[120px]">First Name</th> <th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[120px]">First Name</th>
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[160px]">Last Name / Org</th> <th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[160px]">
Last Name / Org
</th>
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[140px]">ORCID</th> <th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[140px]">ORCID</th>
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[160px]">Email</th> <th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[160px]">Email</th>
<th v-if="showContributorTypes" scope="col" class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 w-32">Role</th> <th
v-if="showContributorTypes"
scope="col"
class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 w-32"
>
Role
</th>
<th v-if="canDelete" class="w-16 px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th> <th v-if="canDelete" class="w-16 px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr> </tr>
</thead> </thead>
@ -223,7 +235,9 @@ const perPageOptions = [
handle=".drag-handle" handle=".drag-handle"
> >
<template #item="{ index, element }"> <template #item="{ index, element }">
<tr class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors"> <tr
class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors"
>
<td v-if="canReorder" class="px-2 py-2"> <td v-if="canReorder" class="px-2 py-2">
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"> <div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<BaseIcon :path="mdiDragVariant" :size="18" /> <BaseIcon :path="mdiDragVariant" :size="18" />
@ -234,8 +248,8 @@ const perPageOptions = [
<!-- Name Type Selector --> <!-- Name Type Selector -->
<td class="px-2 py-2"> <td class="px-2 py-2">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<BaseIcon <BaseIcon
:path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount" :path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount"
:size="16" :size="16"
:class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'" :class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'"
:title="element.name_type" :title="element.name_type"
@ -249,7 +263,10 @@ const perPageOptions = [
class="text-[8px] compact-select-mini flex-1" class="text-[8px] compact-select-mini flex-1"
/> />
</div> </div>
<div class="text-red-500 text-[8px] mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.name_type`])"> <div
class="text-red-500 text-[8px] mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.name_type`])"
>
{{ errors[`${relation}.${index}.name_type`][0] }} {{ errors[`${relation}.${index}.name_type`][0] }}
</div> </div>
</td> </td>
@ -266,7 +283,10 @@ const perPageOptions = [
class="text-xs compact-input" class="text-xs compact-input"
/> />
<span v-else class="text-gray-400 text-xs italic"></span> <span v-else class="text-gray-400 text-xs italic"></span>
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])"> <div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])"
>
{{ errors[`${relation}.${index}.first_name`][0] }} {{ errors[`${relation}.${index}.first_name`][0] }}
</div> </div>
</td> </td>
@ -281,7 +301,10 @@ const perPageOptions = [
:placeholder="element.name_type === 'Organizational' ? 'Organization' : 'Last name'" :placeholder="element.name_type === 'Organizational' ? 'Organization' : 'Last name'"
class="text-xs compact-input" class="text-xs compact-input"
/> />
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])"> <div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])"
>
{{ errors[`${relation}.${index}.last_name`][0] }} {{ errors[`${relation}.${index}.last_name`][0] }}
</div> </div>
</td> </td>
@ -295,7 +318,10 @@ const perPageOptions = [
placeholder="0000-0000-0000-0000" placeholder="0000-0000-0000-0000"
class="text-xs compact-input font-mono" class="text-xs compact-input font-mono"
/> />
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.identifier_orcid`])"> <div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.identifier_orcid`])"
>
{{ errors[`${relation}.${index}.identifier_orcid`][0] }} {{ errors[`${relation}.${index}.identifier_orcid`][0] }}
</div> </div>
</td> </td>
@ -310,7 +336,10 @@ const perPageOptions = [
placeholder="email@example.com" placeholder="email@example.com"
class="text-xs compact-input" class="text-xs compact-input"
/> />
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])"> <div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])"
>
{{ errors[`${relation}.${index}.email`][0] }} {{ errors[`${relation}.${index}.email`][0] }}
</div> </div>
</td> </td>
@ -335,10 +364,10 @@ const perPageOptions = [
<!-- Actions --> <!-- Actions -->
<td class="px-2 py-2 whitespace-nowrap"> <td class="px-2 py-2 whitespace-nowrap">
<BaseButton <BaseButton
color="danger" color="danger"
:icon="mdiTrashCan" :icon="mdiTrashCan"
small small
@click.prevent="removeAuthor(index)" @click.prevent="removeAuthor(index)"
class="compact-button" class="compact-button"
/> />
@ -354,29 +383,30 @@ const perPageOptions = [
:key="element.id || index" :key="element.id || index"
class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors" class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors"
> >
<td v-if="canReorder" class="px-2 py-2 text-gray-400"> <td class="px-2 py-2 text-gray-400">
<BaseIcon :path="mdiDragVariant" :size="18" /> <BaseIcon v-if="canReorder && !hasMultiplePages" :path="mdiDragVariant" :size="18" />
</td> </td>
<td class="px-2 py-2 text-xs text-gray-600 dark:text-gray-400">{{ currentPage * perPage + index + 1 }}</td> <td class="px-2 py-2 text-xs text-gray-600 dark:text-gray-400">{{ currentPage * perPage + index + 1 }}</td>
<!-- Name Type Selector --> <!-- Name Type Selector -->
<td class="px-2 py-2"> <td class="px-2 py-2">
<BaseIcon <div class="flex items-center gap-1.5">
:path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount" <BaseIcon
:size="16" :path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount"
:class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'" :size="16"
:title="element.name_type" :class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'"
/> :title="element.name_type"
<FormControl />
required <FormControl
:model-value="element.name_type" required
@update:model-value="updatePerson(index, 'name_type', $event)" v-model="element.name_type"
type="select" type="select"
:options="nameTypeOptions" :options="nameTypeOptions"
:is-read-only="element.status || !canEdit" :is-read-only="element.status == true"
class="text-xs compact-select" class="text-xs compact-select"
:error="getFieldError(index, 'name_type')" :error="getFieldError(index, 'name_type')"
/> />
</div>
<div v-if="getFieldError(index, 'name_type')" class="text-red-500 text-xs mt-0.5"> <div v-if="getFieldError(index, 'name_type')" class="text-red-500 text-xs mt-0.5">
{{ getFieldError(index, 'name_type') }} {{ getFieldError(index, 'name_type') }}
</div> </div>
@ -459,7 +489,7 @@ const perPageOptions = [
@update:model-value="updatePerson(index, 'pivot_contributor_type', $event)" @update:model-value="updatePerson(index, 'pivot_contributor_type', $event)"
type="select" type="select"
:options="contributortypes" :options="contributortypes"
:is-read-only="element.status || !canEdit" :is-read-only="!canEdit"
placeholder="Role" placeholder="Role"
class="text-xs compact-select" class="text-xs compact-select"
:error="getFieldError(index, 'pivot_contributor_type')" :error="getFieldError(index, 'pivot_contributor_type')"
@ -475,8 +505,7 @@ const perPageOptions = [
color="danger" color="danger"
:icon="mdiTrashCan" :icon="mdiTrashCan"
small small
@click="removeAuthor(index)" @click.prevent="removeAuthor(index)"
:disabled="element.status || !canEdit"
title="Remove person" title="Remove person"
class="compact-button" class="compact-button"
/> />
@ -542,9 +571,7 @@ const perPageOptions = [
/> />
</div> </div>
<span class="text-sm text-gray-600 dark:text-gray-400"> <span class="text-sm text-gray-600 dark:text-gray-400"> Page {{ currentPageHuman }} of {{ numPages }} </span>
Page {{ currentPageHuman }} of {{ numPages }}
</span>
</div> </div>
</div> </div>
</template> </template>
@ -568,4 +595,4 @@ const perPageOptions = [
padding: 0.5rem !important; padding: 0.5rem !important;
} }
} }
</style> </style>

View file

@ -13,7 +13,7 @@
</NotificationBar> </NotificationBar>
<FormValidationErrors v-bind:errors="errors" /> <FormValidationErrors v-bind:errors="errors" />
<CardBox :form="true"> <CardBox>
<!-- <FormValidationErrors v-bind:errors="errors" /> --> <!-- <FormValidationErrors v-bind:errors="errors" /> -->
<div class="mb-4"> <div class="mb-4">
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">

View file

@ -3,7 +3,6 @@
<LayoutAuthenticated> <LayoutAuthenticated>
<Head title="Edit dataset" /> <Head title="Edit dataset" />
<!-- Progress Bar for Unsaved Changes -->
<!-- Progress Bar for Unsaved Changes --> <!-- Progress Bar for Unsaved Changes -->
<div v-if="hasUnsavedChanges" class="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-white px-4 py-2.5 text-sm shadow-lg h-14"> <div v-if="hasUnsavedChanges" class="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-white px-4 py-2.5 text-sm shadow-lg h-14">
<div class="container mx-auto flex items-center justify-between h-full"> <div class="container mx-auto flex items-center justify-between h-full">
@ -17,7 +16,7 @@
</svg> </svg>
<span class="font-medium">You have unsaved changes</span> <span class="font-medium">You have unsaved changes</span>
</div> </div>
<BaseButton @click="submitAlternative" label="Save Now" color="white" small :disabled="form.processing" /> <BaseButton @click.prevent="submitAlternative" label="Save Now" color="white" small :disabled="form.processing" />
</div> </div>
</div> </div>
@ -47,18 +46,18 @@
{{ flash.message }} {{ flash.message }}
</NotificationBar> </NotificationBar>
<FormValidationErrors v-bind:errors="errors" /> <FormValidationErrors v-bind:errors="errors" />
<UnsavedChangesWarning
<!-- Main Form with Sections -->
<CardBox :form="true" class="shadow-lg">
<UnsavedChangesWarning
:show="hasUnsavedChanges" :show="hasUnsavedChanges"
:changes-summary="getChangesSummary()" :changes-summary="getChangesSummary()"
:show-details="true" :show-details="true"
:show-actions="false" :show-actions="false"
:show-auto-save-progress="true" :show-auto-save-progress="true"
:auto-save-delay="30" :auto-save-delay="30"
@save="submitAlternative" @save.prevent="submitAlternative"
/> />
<!-- Main Form with Sections -->
<CardBox class="shadow-lg">
<!-- Collapsible Sections for Better Organization --> <!-- Collapsible Sections for Better Organization -->
<!-- Section 1: Basic Information --> <!-- Section 1: Basic Information -->
@ -420,7 +419,7 @@
title="Creators" title="Creators"
:icon="mdiBookOpenPageVariant" :icon="mdiBookOpenPageVariant"
:header-icon="mdiPlusCircle" :header-icon="mdiPlusCircle"
@header-icon-click="addNewAuthor()" v-on:header-icon-click="addNewAuthor()"
> >
<div <div
class="mb-4 p-3 bg-blue-50 dark:bg-slate-800 rounded-lg text-sm text-blue-800 dark:text-blue-300 flex items-start gap-2" class="mb-4 p-3 bg-blue-50 dark:bg-slate-800 rounded-lg text-sm text-blue-800 dark:text-blue-300 flex items-start gap-2"
@ -475,7 +474,7 @@
title="Contributors" title="Contributors"
:icon="mdiBookOpenPageVariant" :icon="mdiBookOpenPageVariant"
:header-icon="mdiPlusCircle" :header-icon="mdiPlusCircle"
@header-icon-click="addNewContributor()" v-on:header-icon-click="addNewContributor()"
> >
<div class="mb-2 text-gray-600 text-sm flex items-center gap-2"> <div class="mb-2 text-gray-600 text-sm flex items-center gap-2">
<span>Add contributors by searching existing persons or manually adding new ones.</span> <span>Add contributors by searching existing persons or manually adding new ones.</span>
@ -934,6 +933,7 @@ import FileUploadComponent from '@/Components/FileUpload.vue';
import { MapOptions } from '@/Components/Map/MapOptions'; import { MapOptions } from '@/Components/Map/MapOptions';
import { LatLngBoundsExpression } from 'leaflet'; import { LatLngBoundsExpression } from 'leaflet';
import { LayerOptions } from '@/Components/Map/LayerOptions'; import { LayerOptions } from '@/Components/Map/LayerOptions';
import BaseIcon from '@/Components/BaseIcon.vue';
import { import {
mdiImageText, mdiImageText,
mdiArrowLeftBoldOutline, mdiArrowLeftBoldOutline,