initial commit
This commit is contained in:
30
resources/js/Pages/API/Index.vue
Normal file
30
resources/js/Pages/API/Index.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import ApiTokenManager from '@/Pages/API/Partials/ApiTokenManager.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
|
||||
defineProps({
|
||||
tokens: Array,
|
||||
availablePermissions: Array,
|
||||
defaultPermissions: Array,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="API Tokens">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
API Tokens
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||
<ApiTokenManager
|
||||
:tokens="tokens"
|
||||
:available-permissions="availablePermissions"
|
||||
:default-permissions="defaultPermissions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
255
resources/js/Pages/API/Partials/ApiTokenManager.vue
Normal file
255
resources/js/Pages/API/Partials/ApiTokenManager.vue
Normal file
@ -0,0 +1,255 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/inertia-vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import Checkbox from '@/Components/Checkbox.vue';
|
||||
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import SectionBorder from '@/Components/SectionBorder.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
tokens: Array,
|
||||
availablePermissions: Array,
|
||||
defaultPermissions: Array,
|
||||
});
|
||||
|
||||
const createApiTokenForm = useForm({
|
||||
name: '',
|
||||
permissions: props.defaultPermissions,
|
||||
});
|
||||
|
||||
const updateApiTokenForm = useForm({
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
const deleteApiTokenForm = useForm();
|
||||
|
||||
const displayingToken = ref(false);
|
||||
const managingPermissionsFor = ref(null);
|
||||
const apiTokenBeingDeleted = ref(null);
|
||||
|
||||
const createApiToken = () => {
|
||||
createApiTokenForm.post(route('api-tokens.store'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
displayingToken.value = true;
|
||||
createApiTokenForm.reset();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const manageApiTokenPermissions = (token) => {
|
||||
updateApiTokenForm.permissions = token.abilities;
|
||||
managingPermissionsFor.value = token;
|
||||
};
|
||||
|
||||
const updateApiToken = () => {
|
||||
updateApiTokenForm.put(route('api-tokens.update', managingPermissionsFor.value), {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => (managingPermissionsFor.value = null),
|
||||
});
|
||||
};
|
||||
|
||||
const confirmApiTokenDeletion = (token) => {
|
||||
apiTokenBeingDeleted.value = token;
|
||||
};
|
||||
|
||||
const deleteApiToken = () => {
|
||||
deleteApiTokenForm.delete(route('api-tokens.destroy', apiTokenBeingDeleted.value), {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => (apiTokenBeingDeleted.value = null),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Generate API Token -->
|
||||
<FormSection @submitted="createApiToken">
|
||||
<template #title>
|
||||
Create API Token
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
API tokens allow third-party services to authenticate with our application on your behalf.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<!-- Token Name -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="name" value="Name" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="createApiTokenForm.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autofocus
|
||||
/>
|
||||
<InputError :message="createApiTokenForm.errors.name" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Token Permissions -->
|
||||
<div v-if="availablePermissions.length > 0" class="col-span-6">
|
||||
<InputLabel for="permissions" value="Permissions" />
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="permission in availablePermissions" :key="permission">
|
||||
<label class="flex items-center">
|
||||
<Checkbox v-model:checked="createApiTokenForm.permissions" :value="permission" />
|
||||
<span class="ml-2 text-sm text-gray-600">{{ permission }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="createApiTokenForm.recentlySuccessful" class="mr-3">
|
||||
Created.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': createApiTokenForm.processing }" :disabled="createApiTokenForm.processing">
|
||||
Create
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
|
||||
<div v-if="tokens.length > 0">
|
||||
<SectionBorder />
|
||||
|
||||
<!-- Manage API Tokens -->
|
||||
<div class="mt-10 sm:mt-0">
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Manage API Tokens
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
You may delete any of your existing tokens if they are no longer needed.
|
||||
</template>
|
||||
|
||||
<!-- API Token List -->
|
||||
<template #content>
|
||||
<div class="space-y-6">
|
||||
<div v-for="token in tokens" :key="token.id" class="flex items-center justify-between">
|
||||
<div>
|
||||
{{ token.name }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div v-if="token.last_used_ago" class="text-sm text-gray-400">
|
||||
Last used {{ token.last_used_ago }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="availablePermissions.length > 0"
|
||||
class="cursor-pointer ml-6 text-sm text-gray-400 underline"
|
||||
@click="manageApiTokenPermissions(token)"
|
||||
>
|
||||
Permissions
|
||||
</button>
|
||||
|
||||
<button class="cursor-pointer ml-6 text-sm text-red-500" @click="confirmApiTokenDeletion(token)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Value Modal -->
|
||||
<DialogModal :show="displayingToken" @close="displayingToken = false">
|
||||
<template #title>
|
||||
API Token
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div>
|
||||
Please copy your new API token. For your security, it won't be shown again.
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.jetstream.flash.token" class="mt-4 bg-gray-100 px-4 py-2 rounded font-mono text-sm text-gray-500">
|
||||
{{ $page.props.jetstream.flash.token }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="displayingToken = false">
|
||||
Close
|
||||
</SecondaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
<!-- API Token Permissions Modal -->
|
||||
<DialogModal :show="managingPermissionsFor != null" @close="managingPermissionsFor = null">
|
||||
<template #title>
|
||||
API Token Permissions
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="permission in availablePermissions" :key="permission">
|
||||
<label class="flex items-center">
|
||||
<Checkbox v-model:checked="updateApiTokenForm.permissions" :value="permission" />
|
||||
<span class="ml-2 text-sm text-gray-600">{{ permission }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="managingPermissionsFor = null">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ml-3"
|
||||
:class="{ 'opacity-25': updateApiTokenForm.processing }"
|
||||
:disabled="updateApiTokenForm.processing"
|
||||
@click="updateApiToken"
|
||||
>
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
<!-- Delete Token Confirmation Modal -->
|
||||
<ConfirmationModal :show="apiTokenBeingDeleted != null" @close="apiTokenBeingDeleted = null">
|
||||
<template #title>
|
||||
Delete API Token
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you would like to delete this API token?
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="apiTokenBeingDeleted = null">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ml-3"
|
||||
:class="{ 'opacity-25': deleteApiTokenForm.processing }"
|
||||
:disabled="deleteApiTokenForm.processing"
|
||||
@click="deleteApiToken"
|
||||
>
|
||||
Delete
|
||||
</DangerButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
</template>
|
63
resources/js/Pages/Auth/ConfirmPassword.vue
Normal file
63
resources/js/Pages/Auth/ConfirmPassword.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { Head, useForm } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const form = useForm({
|
||||
password: '',
|
||||
});
|
||||
|
||||
const passwordInput = ref(null);
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('password.confirm'), {
|
||||
onFinish: () => {
|
||||
form.reset();
|
||||
|
||||
passwordInput.value.focus();
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Secure Area" />
|
||||
|
||||
<AuthenticationCard>
|
||||
<template #logo>
|
||||
<AuthenticationCardLogo />
|
||||
</template>
|
||||
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
This is a secure area of the application. Please confirm your password before continuing.
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<InputLabel for="password" value="Password" />
|
||||
<TextInput
|
||||
id="password"
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<PrimaryButton class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Confirm
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</AuthenticationCard>
|
||||
</template>
|
60
resources/js/Pages/Auth/ForgotPassword.vue
Normal file
60
resources/js/Pages/Auth/ForgotPassword.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { Head, useForm } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
defineProps({
|
||||
status: String,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('password.email'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Forgot Password" />
|
||||
|
||||
<AuthenticationCard>
|
||||
<template #logo>
|
||||
<AuthenticationCardLogo />
|
||||
</template>
|
||||
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="mb-4 font-medium text-sm text-green-600">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Email Password Reset Link
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</AuthenticationCard>
|
||||
</template>
|
89
resources/js/Pages/Auth/Login.vue
Normal file
89
resources/js/Pages/Auth/Login.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { Head, Link, useForm } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
import Checkbox from '@/Components/Checkbox.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
defineProps({
|
||||
canResetPassword: Boolean,
|
||||
status: String,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.transform(data => ({
|
||||
...data,
|
||||
remember: form.remember ? 'on' : '',
|
||||
})).post(route('login'), {
|
||||
onFinish: () => form.reset('password'),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Log in" />
|
||||
|
||||
<AuthenticationCard>
|
||||
<template #logo>
|
||||
<AuthenticationCardLogo />
|
||||
</template>
|
||||
|
||||
<div v-if="status" class="mb-4 font-medium text-sm text-green-600">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<InputLabel for="password" value="Password" />
|
||||
<TextInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="block mt-4">
|
||||
<label class="flex items-center">
|
||||
<Checkbox v-model:checked="form.remember" name="remember" />
|
||||
<span class="ml-2 text-sm text-gray-600">Remember me</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<Link v-if="canResetPassword" :href="route('password.request')" class="underline text-sm text-gray-600 hover:text-gray-900">
|
||||
Forgot your password?
|
||||
</Link>
|
||||
|
||||
<PrimaryButton class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Log in
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</AuthenticationCard>
|
||||
</template>
|
111
resources/js/Pages/Auth/Register.vue
Normal file
111
resources/js/Pages/Auth/Register.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { Head, Link, useForm } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
import Checkbox from '@/Components/Checkbox.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
terms: false,
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('register'), {
|
||||
onFinish: () => form.reset('password', 'password_confirmation'),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Register" />
|
||||
|
||||
<AuthenticationCard>
|
||||
<template #logo>
|
||||
<AuthenticationCardLogo />
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<InputLabel for="name" value="Name" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<InputLabel for="password" value="Password" />
|
||||
<TextInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<InputLabel for="password_confirmation" value="Confirm Password" />
|
||||
<TextInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.jetstream.hasTermsAndPrivacyPolicyFeature" class="mt-4">
|
||||
<InputLabel for="terms">
|
||||
<div class="flex items-center">
|
||||
<Checkbox id="terms" v-model:checked="form.terms" name="terms" required />
|
||||
|
||||
<div class="ml-2">
|
||||
I agree to the <a target="_blank" :href="route('terms.show')" class="underline text-sm text-gray-600 hover:text-gray-900">Terms of Service</a> and <a target="_blank" :href="route('policy.show')" class="underline text-sm text-gray-600 hover:text-gray-900">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
<InputError class="mt-2" :message="form.errors.terms" />
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<Link :href="route('login')" class="underline text-sm text-gray-600 hover:text-gray-900">
|
||||
Already registered?
|
||||
</Link>
|
||||
|
||||
<PrimaryButton class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Register
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</AuthenticationCard>
|
||||
</template>
|
84
resources/js/Pages/Auth/ResetPassword.vue
Normal file
84
resources/js/Pages/Auth/ResetPassword.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { Head, useForm } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
email: String,
|
||||
token: String,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
token: props.token,
|
||||
email: props.email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('password.update'), {
|
||||
onFinish: () => form.reset('password', 'password_confirmation'),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Reset Password" />
|
||||
|
||||
<AuthenticationCard>
|
||||
<template #logo>
|
||||
<AuthenticationCardLogo />
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<InputLabel for="password" value="Password" />
|
||||
<TextInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<InputLabel for="password_confirmation" value="Confirm Password" />
|
||||
<TextInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Reset Password
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</AuthenticationCard>
|
||||
</template>
|
104
resources/js/Pages/Auth/TwoFactorChallenge.vue
Normal file
104
resources/js/Pages/Auth/TwoFactorChallenge.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { Head, useForm } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const recovery = ref(false);
|
||||
|
||||
const form = useForm({
|
||||
code: '',
|
||||
recovery_code: '',
|
||||
});
|
||||
|
||||
const recoveryCodeInput = ref(null);
|
||||
const codeInput = ref(null);
|
||||
|
||||
const toggleRecovery = async () => {
|
||||
recovery.value ^= true;
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (recovery.value) {
|
||||
recoveryCodeInput.value.focus();
|
||||
form.code = '';
|
||||
} else {
|
||||
codeInput.value.focus();
|
||||
form.recovery_code = '';
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('two-factor.login'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Two-factor Confirmation" />
|
||||
|
||||
<AuthenticationCard>
|
||||
<template #logo>
|
||||
<AuthenticationCardLogo />
|
||||
</template>
|
||||
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
<template v-if="! recovery">
|
||||
Please confirm access to your account by entering the authentication code provided by your authenticator application.
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
Please confirm access to your account by entering one of your emergency recovery codes.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div v-if="! recovery">
|
||||
<InputLabel for="code" value="Code" />
|
||||
<TextInput
|
||||
id="code"
|
||||
ref="codeInput"
|
||||
v-model="form.code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="mt-1 block w-full"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.code" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<InputLabel for="recovery_code" value="Recovery Code" />
|
||||
<TextInput
|
||||
id="recovery_code"
|
||||
ref="recoveryCodeInput"
|
||||
v-model="form.recovery_code"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<InputError class="mt-2" :message="form.errors.recovery_code" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<button type="button" class="text-sm text-gray-600 hover:text-gray-900 underline cursor-pointer" @click.prevent="toggleRecovery">
|
||||
<template v-if="! recovery">
|
||||
Use a recovery code
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
Use an authentication code
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<PrimaryButton class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Log in
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</AuthenticationCard>
|
||||
</template>
|
62
resources/js/Pages/Auth/VerifyEmail.vue
Normal file
62
resources/js/Pages/Auth/VerifyEmail.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Head, Link, useForm } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: String,
|
||||
});
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('verification.send'));
|
||||
};
|
||||
|
||||
const verificationLinkSent = computed(() => props.status === 'verification-link-sent');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Email Verification" />
|
||||
|
||||
<AuthenticationCard>
|
||||
<template #logo>
|
||||
<AuthenticationCardLogo />
|
||||
</template>
|
||||
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.
|
||||
</div>
|
||||
|
||||
<div v-if="verificationLinkSent" class="mb-4 font-medium text-sm text-green-600">
|
||||
A new verification link has been sent to the email address you provided in your profile settings.
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Resend Verification Email
|
||||
</PrimaryButton>
|
||||
|
||||
<div>
|
||||
<Link
|
||||
:href="route('profile.show')"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Edit Profile</Link>
|
||||
|
||||
<Link
|
||||
:href="route('logout')"
|
||||
method="post"
|
||||
as="button"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 ml-2"
|
||||
>
|
||||
Log Out
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AuthenticationCard>
|
||||
</template>
|
22
resources/js/Pages/Dashboard.vue
Normal file
22
resources/js/Pages/Dashboard.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Welcome from '@/Components/Welcome.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Dashboard">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Dashboard
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<Welcome />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
24
resources/js/Pages/PrivacyPolicy.vue
Normal file
24
resources/js/Pages/PrivacyPolicy.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { Head } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
|
||||
defineProps({
|
||||
policy: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Privacy Policy" />
|
||||
|
||||
<div class="font-sans text-gray-900 antialiased">
|
||||
<div class="pt-4 bg-gray-100">
|
||||
<div class="min-h-screen flex flex-col items-center pt-6 sm:pt-0">
|
||||
<div>
|
||||
<AuthenticationCardLogo />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:max-w-2xl mt-6 p-6 bg-white shadow-md overflow-hidden sm:rounded-lg prose" v-html="policy" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
101
resources/js/Pages/Profile/Partials/DeleteUserForm.vue
Normal file
101
resources/js/Pages/Profile/Partials/DeleteUserForm.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/inertia-vue3';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const confirmingUserDeletion = ref(false);
|
||||
const passwordInput = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
password: '',
|
||||
});
|
||||
|
||||
const confirmUserDeletion = () => {
|
||||
confirmingUserDeletion.value = true;
|
||||
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
};
|
||||
|
||||
const deleteUser = () => {
|
||||
form.delete(route('current-user.destroy'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => passwordInput.value.focus(),
|
||||
onFinish: () => form.reset(),
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
confirmingUserDeletion.value = false;
|
||||
|
||||
form.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Delete Account
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Permanently delete your account.
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-xl text-sm text-gray-600">
|
||||
Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<DangerButton @click="confirmUserDeletion">
|
||||
Delete Account
|
||||
</DangerButton>
|
||||
</div>
|
||||
|
||||
<!-- Delete Account Confirmation Modal -->
|
||||
<DialogModal :show="confirmingUserDeletion" @close="closeModal">
|
||||
<template #title>
|
||||
Delete Account
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.
|
||||
|
||||
<div class="mt-4">
|
||||
<TextInput
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
@keyup.enter="deleteUser"
|
||||
/>
|
||||
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ml-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="deleteUser"
|
||||
>
|
||||
Delete Account
|
||||
</DangerButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</template>
|
@ -0,0 +1,165 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/inertia-vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
defineProps({
|
||||
sessions: Array,
|
||||
});
|
||||
|
||||
const confirmingLogout = ref(false);
|
||||
const passwordInput = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
password: '',
|
||||
});
|
||||
|
||||
const confirmLogout = () => {
|
||||
confirmingLogout.value = true;
|
||||
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
};
|
||||
|
||||
const logoutOtherBrowserSessions = () => {
|
||||
form.delete(route('other-browser-sessions.destroy'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => passwordInput.value.focus(),
|
||||
onFinish: () => form.reset(),
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
confirmingLogout.value = false;
|
||||
|
||||
form.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Browser Sessions
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Manage and log out your active sessions on other browsers and devices.
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-xl text-sm text-gray-600">
|
||||
If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.
|
||||
</div>
|
||||
|
||||
<!-- Other Browser Sessions -->
|
||||
<div v-if="sessions.length > 0" class="mt-5 space-y-6">
|
||||
<div v-for="(session, i) in sessions" :key="i" class="flex items-center">
|
||||
<div>
|
||||
<svg
|
||||
v-if="session.agent.is_desktop"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
class="w-8 h-8 text-gray-500"
|
||||
>
|
||||
<path d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-8 h-8 text-gray-500"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" stroke="none" /><rect
|
||||
x="7"
|
||||
y="4"
|
||||
width="10"
|
||||
height="16"
|
||||
rx="1"
|
||||
/><path d="M11 5h2M12 17v.01" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="ml-3">
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ session.agent.platform ? session.agent.platform : 'Unknown' }} - {{ session.agent.browser ? session.agent.browser : 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ session.ip_address }},
|
||||
|
||||
<span v-if="session.is_current_device" class="text-green-500 font-semibold">This device</span>
|
||||
<span v-else>Last active {{ session.last_active }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-5">
|
||||
<PrimaryButton @click="confirmLogout">
|
||||
Log Out Other Browser Sessions
|
||||
</PrimaryButton>
|
||||
|
||||
<ActionMessage :on="form.recentlySuccessful" class="ml-3">
|
||||
Done.
|
||||
</ActionMessage>
|
||||
</div>
|
||||
|
||||
<!-- Log Out Other Devices Confirmation Modal -->
|
||||
<DialogModal :show="confirmingLogout" @close="closeModal">
|
||||
<template #title>
|
||||
Log Out Other Browser Sessions
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.
|
||||
|
||||
<div class="mt-4">
|
||||
<TextInput
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
@keyup.enter="logoutOtherBrowserSessions"
|
||||
/>
|
||||
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ml-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="logoutOtherBrowserSessions"
|
||||
>
|
||||
Log Out Other Browser Sessions
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</template>
|
@ -0,0 +1,253 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { Inertia } from '@inertiajs/inertia';
|
||||
import { useForm, usePage } from '@inertiajs/inertia-vue3';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import ConfirmsPassword from '@/Components/ConfirmsPassword.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
requiresConfirmation: Boolean,
|
||||
});
|
||||
|
||||
const enabling = ref(false);
|
||||
const confirming = ref(false);
|
||||
const disabling = ref(false);
|
||||
const qrCode = ref(null);
|
||||
const setupKey = ref(null);
|
||||
const recoveryCodes = ref([]);
|
||||
|
||||
const confirmationForm = useForm({
|
||||
code: '',
|
||||
});
|
||||
|
||||
const twoFactorEnabled = computed(
|
||||
() => ! enabling.value && usePage().props.value.user?.two_factor_enabled,
|
||||
);
|
||||
|
||||
watch(twoFactorEnabled, () => {
|
||||
if (! twoFactorEnabled.value) {
|
||||
confirmationForm.reset();
|
||||
confirmationForm.clearErrors();
|
||||
}
|
||||
});
|
||||
|
||||
const enableTwoFactorAuthentication = () => {
|
||||
enabling.value = true;
|
||||
|
||||
Inertia.post('/user/two-factor-authentication', {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => Promise.all([
|
||||
showQrCode(),
|
||||
showSetupKey(),
|
||||
showRecoveryCodes(),
|
||||
]),
|
||||
onFinish: () => {
|
||||
enabling.value = false;
|
||||
confirming.value = props.requiresConfirmation;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showQrCode = () => {
|
||||
return axios.get('/user/two-factor-qr-code').then(response => {
|
||||
qrCode.value = response.data.svg;
|
||||
});
|
||||
};
|
||||
|
||||
const showSetupKey = () => {
|
||||
return axios.get('/user/two-factor-secret-key').then(response => {
|
||||
setupKey.value = response.data.secretKey;
|
||||
});
|
||||
}
|
||||
|
||||
const showRecoveryCodes = () => {
|
||||
return axios.get('/user/two-factor-recovery-codes').then(response => {
|
||||
recoveryCodes.value = response.data;
|
||||
});
|
||||
};
|
||||
|
||||
const confirmTwoFactorAuthentication = () => {
|
||||
confirmationForm.post('/user/confirmed-two-factor-authentication', {
|
||||
errorBag: "confirmTwoFactorAuthentication",
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => {
|
||||
confirming.value = false;
|
||||
qrCode.value = null;
|
||||
setupKey.value = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const regenerateRecoveryCodes = () => {
|
||||
axios
|
||||
.post('/user/two-factor-recovery-codes')
|
||||
.then(() => showRecoveryCodes());
|
||||
};
|
||||
|
||||
const disableTwoFactorAuthentication = () => {
|
||||
disabling.value = true;
|
||||
|
||||
Inertia.delete('/user/two-factor-authentication', {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
disabling.value = false;
|
||||
confirming.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Two Factor Authentication
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Add additional security to your account using two factor authentication.
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-medium text-gray-900">
|
||||
You have enabled two factor authentication.
|
||||
</h3>
|
||||
|
||||
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-medium text-gray-900">
|
||||
Finish enabling two factor authentication.
|
||||
</h3>
|
||||
|
||||
<h3 v-else class="text-lg font-medium text-gray-900">
|
||||
You have not enabled two factor authentication.
|
||||
</h3>
|
||||
|
||||
<div class="mt-3 max-w-xl text-sm text-gray-600">
|
||||
<p>
|
||||
When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="twoFactorEnabled">
|
||||
<div v-if="qrCode">
|
||||
<div class="mt-4 max-w-xl text-sm text-gray-600">
|
||||
<p v-if="confirming" class="font-semibold">
|
||||
To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.
|
||||
</p>
|
||||
|
||||
<p v-else>
|
||||
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4" v-html="qrCode" />
|
||||
|
||||
<div class="mt-4 max-w-xl text-sm text-gray-600" v-if="setupKey">
|
||||
<p class="font-semibold">
|
||||
Setup Key: <span v-html="setupKey"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="confirming" class="mt-4">
|
||||
<InputLabel for="code" value="Code" />
|
||||
|
||||
<TextInput
|
||||
id="code"
|
||||
v-model="confirmationForm.code"
|
||||
type="text"
|
||||
name="code"
|
||||
class="block mt-1 w-1/2"
|
||||
inputmode="numeric"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
@keyup.enter="confirmTwoFactorAuthentication"
|
||||
/>
|
||||
|
||||
<InputError :message="confirmationForm.errors.code" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recoveryCodes.length > 0 && ! confirming">
|
||||
<div class="mt-4 max-w-xl text-sm text-gray-600">
|
||||
<p class="font-semibold">
|
||||
Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-1 max-w-xl mt-4 px-4 py-4 font-mono text-sm bg-gray-100 rounded-lg">
|
||||
<div v-for="code in recoveryCodes" :key="code">
|
||||
{{ code }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<div v-if="! twoFactorEnabled">
|
||||
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
|
||||
<PrimaryButton type="button" :class="{ 'opacity-25': enabling }" :disabled="enabling">
|
||||
Enable
|
||||
</PrimaryButton>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
|
||||
<PrimaryButton
|
||||
v-if="confirming"
|
||||
type="button"
|
||||
class="mr-3"
|
||||
:class="{ 'opacity-25': enabling }"
|
||||
:disabled="enabling"
|
||||
>
|
||||
Confirm
|
||||
</PrimaryButton>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
|
||||
<SecondaryButton
|
||||
v-if="recoveryCodes.length > 0 && ! confirming"
|
||||
class="mr-3"
|
||||
>
|
||||
Regenerate Recovery Codes
|
||||
</SecondaryButton>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="showRecoveryCodes">
|
||||
<SecondaryButton
|
||||
v-if="recoveryCodes.length === 0 && ! confirming"
|
||||
class="mr-3"
|
||||
>
|
||||
Show Recovery Codes
|
||||
</SecondaryButton>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<SecondaryButton
|
||||
v-if="confirming"
|
||||
:class="{ 'opacity-25': disabling }"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<DangerButton
|
||||
v-if="! confirming"
|
||||
:class="{ 'opacity-25': disabling }"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Disable
|
||||
</DangerButton>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</template>
|
100
resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue
Normal file
100
resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/inertia-vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const passwordInput = ref(null);
|
||||
const currentPasswordInput = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const updatePassword = () => {
|
||||
form.put(route('user-password.update'), {
|
||||
errorBag: 'updatePassword',
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
onError: () => {
|
||||
if (form.errors.password) {
|
||||
form.reset('password', 'password_confirmation');
|
||||
passwordInput.value.focus();
|
||||
}
|
||||
|
||||
if (form.errors.current_password) {
|
||||
form.reset('current_password');
|
||||
currentPasswordInput.value.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="updatePassword">
|
||||
<template #title>
|
||||
Update Password
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Ensure your account is using a long, random password to stay secure.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="current_password" value="Current Password" />
|
||||
<TextInput
|
||||
id="current_password"
|
||||
ref="currentPasswordInput"
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<InputError :message="form.errors.current_password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="password" value="New Password" />
|
||||
<TextInput
|
||||
id="password"
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="password_confirmation" value="Confirm Password" />
|
||||
<TextInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password_confirmation" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="form.recentlySuccessful" class="mr-3">
|
||||
Saved.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
@ -0,0 +1,187 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { Inertia } from '@inertiajs/inertia';
|
||||
import { Link, useForm } from '@inertiajs/inertia-vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
user: Object,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
_method: 'PUT',
|
||||
name: props.user.name,
|
||||
email: props.user.email,
|
||||
photo: null,
|
||||
});
|
||||
|
||||
const verificationLinkSent = ref(null);
|
||||
const photoPreview = ref(null);
|
||||
const photoInput = ref(null);
|
||||
|
||||
const updateProfileInformation = () => {
|
||||
if (photoInput.value) {
|
||||
form.photo = photoInput.value.files[0];
|
||||
}
|
||||
|
||||
form.post(route('user-profile-information.update'), {
|
||||
errorBag: 'updateProfileInformation',
|
||||
preserveScroll: true,
|
||||
onSuccess: () => clearPhotoFileInput(),
|
||||
});
|
||||
};
|
||||
|
||||
const sendEmailVerification = () => {
|
||||
verificationLinkSent.value = true;
|
||||
};
|
||||
|
||||
const selectNewPhoto = () => {
|
||||
photoInput.value.click();
|
||||
};
|
||||
|
||||
const updatePhotoPreview = () => {
|
||||
const photo = photoInput.value.files[0];
|
||||
|
||||
if (! photo) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
photoPreview.value = e.target.result;
|
||||
};
|
||||
|
||||
reader.readAsDataURL(photo);
|
||||
};
|
||||
|
||||
const deletePhoto = () => {
|
||||
Inertia.delete(route('current-user-photo.destroy'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
photoPreview.value = null;
|
||||
clearPhotoFileInput();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const clearPhotoFileInput = () => {
|
||||
if (photoInput.value?.value) {
|
||||
photoInput.value.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="updateProfileInformation">
|
||||
<template #title>
|
||||
Profile Information
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Update your account's profile information and email address.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<!-- Profile Photo -->
|
||||
<div v-if="$page.props.jetstream.managesProfilePhotos" class="col-span-6 sm:col-span-4">
|
||||
<!-- Profile Photo File Input -->
|
||||
<input
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="updatePhotoPreview"
|
||||
>
|
||||
|
||||
<InputLabel for="photo" value="Photo" />
|
||||
|
||||
<!-- Current Profile Photo -->
|
||||
<div v-show="! photoPreview" class="mt-2">
|
||||
<img :src="user.profile_photo_url" :alt="user.name" class="rounded-full h-20 w-20 object-cover">
|
||||
</div>
|
||||
|
||||
<!-- New Profile Photo Preview -->
|
||||
<div v-show="photoPreview" class="mt-2">
|
||||
<span
|
||||
class="block rounded-full w-20 h-20 bg-cover bg-no-repeat bg-center"
|
||||
:style="'background-image: url(\'' + photoPreview + '\');'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SecondaryButton class="mt-2 mr-2" type="button" @click.prevent="selectNewPhoto">
|
||||
Select A New Photo
|
||||
</SecondaryButton>
|
||||
|
||||
<SecondaryButton
|
||||
v-if="user.profile_photo_path"
|
||||
type="button"
|
||||
class="mt-2"
|
||||
@click.prevent="deletePhoto"
|
||||
>
|
||||
Remove Photo
|
||||
</SecondaryButton>
|
||||
|
||||
<InputError :message="form.errors.photo" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="name" value="Name" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="name"
|
||||
/>
|
||||
<InputError :message="form.errors.name" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
/>
|
||||
<InputError :message="form.errors.email" class="mt-2" />
|
||||
|
||||
<div v-if="$page.props.jetstream.hasEmailVerification && user.email_verified_at === null">
|
||||
<p class="text-sm mt-2">
|
||||
Your email address is unverified.
|
||||
|
||||
<Link
|
||||
:href="route('verification.send')"
|
||||
method="post"
|
||||
as="button"
|
||||
class="underline text-gray-600 hover:text-gray-900"
|
||||
@click.prevent="sendEmailVerification"
|
||||
>
|
||||
Click here to re-send the verification email.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div v-show="verificationLinkSent" class="mt-2 font-medium text-sm text-green-600">
|
||||
A new verification link has been sent to your email address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="form.recentlySuccessful" class="mr-3">
|
||||
Saved.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
57
resources/js/Pages/Profile/Show.vue
Normal file
57
resources/js/Pages/Profile/Show.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
|
||||
import LogoutOtherBrowserSessionsForm from '@/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.vue';
|
||||
import SectionBorder from '@/Components/SectionBorder.vue';
|
||||
import TwoFactorAuthenticationForm from '@/Pages/Profile/Partials/TwoFactorAuthenticationForm.vue';
|
||||
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
|
||||
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
|
||||
|
||||
defineProps({
|
||||
confirmsTwoFactorAuthentication: Boolean,
|
||||
sessions: Array,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Profile">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Profile
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||
<div v-if="$page.props.jetstream.canUpdateProfileInformation">
|
||||
<UpdateProfileInformationForm :user="$page.props.user" />
|
||||
|
||||
<SectionBorder />
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.jetstream.canUpdatePassword">
|
||||
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
||||
|
||||
<SectionBorder />
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.jetstream.canManageTwoFactorAuthentication">
|
||||
<TwoFactorAuthenticationForm
|
||||
:requires-confirmation="confirmsTwoFactorAuthentication"
|
||||
class="mt-10 sm:mt-0"
|
||||
/>
|
||||
|
||||
<SectionBorder />
|
||||
</div>
|
||||
|
||||
<LogoutOtherBrowserSessionsForm :sessions="sessions" class="mt-10 sm:mt-0" />
|
||||
|
||||
<template v-if="$page.props.jetstream.hasAccountDeletionFeatures">
|
||||
<SectionBorder />
|
||||
|
||||
<DeleteUserForm class="mt-10 sm:mt-0" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
20
resources/js/Pages/Teams/Create.vue
Normal file
20
resources/js/Pages/Teams/Create.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import CreateTeamForm from '@/Pages/Teams/Partials/CreateTeamForm.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Create Team">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Create Team
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||
<CreateTeamForm />
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
66
resources/js/Pages/Teams/Partials/CreateTeamForm.vue
Normal file
66
resources/js/Pages/Teams/Partials/CreateTeamForm.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/inertia-vue3';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
});
|
||||
|
||||
const createTeam = () => {
|
||||
form.post(route('teams.store'), {
|
||||
errorBag: 'createTeam',
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="createTeam">
|
||||
<template #title>
|
||||
Team Details
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Create a new team to collaborate with others on projects.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6">
|
||||
<InputLabel value="Team Owner" />
|
||||
|
||||
<div class="flex items-center mt-2">
|
||||
<img class="object-cover w-12 h-12 rounded-full" :src="$page.props.user.profile_photo_url" :alt="$page.props.user.name">
|
||||
|
||||
<div class="ml-4 leading-tight">
|
||||
<div>{{ $page.props.user.name }}</div>
|
||||
<div class="text-sm text-gray-700">
|
||||
{{ $page.props.user.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="name" value="Team Name" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="block w-full mt-1"
|
||||
autofocus
|
||||
/>
|
||||
<InputError :message="form.errors.name" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Create
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
75
resources/js/Pages/Teams/Partials/DeleteTeamForm.vue
Normal file
75
resources/js/Pages/Teams/Partials/DeleteTeamForm.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/inertia-vue3';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
team: Object,
|
||||
});
|
||||
|
||||
const confirmingTeamDeletion = ref(false);
|
||||
const form = useForm();
|
||||
|
||||
const confirmTeamDeletion = () => {
|
||||
confirmingTeamDeletion.value = true;
|
||||
};
|
||||
|
||||
const deleteTeam = () => {
|
||||
form.delete(route('teams.destroy', props.team), {
|
||||
errorBag: 'deleteTeam',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Delete Team
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Permanently delete this team.
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-xl text-sm text-gray-600">
|
||||
Once a team is deleted, all of its resources and data will be permanently deleted. Before deleting this team, please download any data or information regarding this team that you wish to retain.
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<DangerButton @click="confirmTeamDeletion">
|
||||
Delete Team
|
||||
</DangerButton>
|
||||
</div>
|
||||
|
||||
<!-- Delete Team Confirmation Modal -->
|
||||
<ConfirmationModal :show="confirmingTeamDeletion" @close="confirmingTeamDeletion = false">
|
||||
<template #title>
|
||||
Delete Team
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you want to delete this team? Once a team is deleted, all of its resources and data will be permanently deleted.
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="confirmingTeamDeletion = false">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ml-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="deleteTeam"
|
||||
>
|
||||
Delete Team
|
||||
</DangerButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</template>
|
396
resources/js/Pages/Teams/Partials/TeamMemberManager.vue
Normal file
396
resources/js/Pages/Teams/Partials/TeamMemberManager.vue
Normal file
@ -0,0 +1,396 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { Inertia } from '@inertiajs/inertia';
|
||||
import { useForm, usePage } from '@inertiajs/inertia-vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import SectionBorder from '@/Components/SectionBorder.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
team: Object,
|
||||
availableRoles: Array,
|
||||
userPermissions: Object,
|
||||
});
|
||||
|
||||
const addTeamMemberForm = useForm({
|
||||
email: '',
|
||||
role: null,
|
||||
});
|
||||
|
||||
const updateRoleForm = useForm({
|
||||
role: null,
|
||||
});
|
||||
|
||||
const leaveTeamForm = useForm();
|
||||
const removeTeamMemberForm = useForm();
|
||||
|
||||
const currentlyManagingRole = ref(false);
|
||||
const managingRoleFor = ref(null);
|
||||
const confirmingLeavingTeam = ref(false);
|
||||
const teamMemberBeingRemoved = ref(null);
|
||||
|
||||
const addTeamMember = () => {
|
||||
addTeamMemberForm.post(route('team-members.store', props.team), {
|
||||
errorBag: 'addTeamMember',
|
||||
preserveScroll: true,
|
||||
onSuccess: () => addTeamMemberForm.reset(),
|
||||
});
|
||||
};
|
||||
|
||||
const cancelTeamInvitation = (invitation) => {
|
||||
Inertia.delete(route('team-invitations.destroy', invitation), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const manageRole = (teamMember) => {
|
||||
managingRoleFor.value = teamMember;
|
||||
updateRoleForm.role = teamMember.membership.role;
|
||||
currentlyManagingRole.value = true;
|
||||
};
|
||||
|
||||
const updateRole = () => {
|
||||
updateRoleForm.put(route('team-members.update', [props.team, managingRoleFor.value]), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => currentlyManagingRole.value = false,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmLeavingTeam = () => {
|
||||
confirmingLeavingTeam.value = true;
|
||||
};
|
||||
|
||||
const leaveTeam = () => {
|
||||
leaveTeamForm.delete(route('team-members.destroy', [props.team, usePage().props.value.user]));
|
||||
};
|
||||
|
||||
const confirmTeamMemberRemoval = (teamMember) => {
|
||||
teamMemberBeingRemoved.value = teamMember;
|
||||
};
|
||||
|
||||
const removeTeamMember = () => {
|
||||
removeTeamMemberForm.delete(route('team-members.destroy', [props.team, teamMemberBeingRemoved.value]), {
|
||||
errorBag: 'removeTeamMember',
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => teamMemberBeingRemoved.value = null,
|
||||
});
|
||||
};
|
||||
|
||||
const displayableRole = (role) => {
|
||||
return props.availableRoles.find(r => r.key === role).name;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="userPermissions.canAddTeamMembers">
|
||||
<SectionBorder />
|
||||
|
||||
<!-- Add Team Member -->
|
||||
<FormSection @submitted="addTeamMember">
|
||||
<template #title>
|
||||
Add Team Member
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
Add a new team member to your team, allowing them to collaborate with you.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6">
|
||||
<div class="max-w-xl text-sm text-gray-600">
|
||||
Please provide the email address of the person you would like to add to this team.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member Email -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
v-model="addTeamMemberForm.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
/>
|
||||
<InputError :message="addTeamMemberForm.errors.email" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div v-if="availableRoles.length > 0" class="col-span-6 lg:col-span-4">
|
||||
<InputLabel for="roles" value="Role" />
|
||||
<InputError :message="addTeamMemberForm.errors.role" class="mt-2" />
|
||||
|
||||
<div class="relative z-0 mt-1 border border-gray-200 rounded-lg cursor-pointer">
|
||||
<button
|
||||
v-for="(role, i) in availableRoles"
|
||||
:key="role.key"
|
||||
type="button"
|
||||
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-blue-300 focus:ring focus:ring-blue-200"
|
||||
:class="{'border-t border-gray-200 rounded-t-none': i > 0, 'rounded-b-none': i != Object.keys(availableRoles).length - 1}"
|
||||
@click="addTeamMemberForm.role = role.key"
|
||||
>
|
||||
<div :class="{'opacity-50': addTeamMemberForm.role && addTeamMemberForm.role != role.key}">
|
||||
<!-- Role Name -->
|
||||
<div class="flex items-center">
|
||||
<div class="text-sm text-gray-600" :class="{'font-semibold': addTeamMemberForm.role == role.key}">
|
||||
{{ role.name }}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
v-if="addTeamMemberForm.role == role.key"
|
||||
class="ml-2 h-5 w-5 text-green-400"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
</div>
|
||||
|
||||
<!-- Role Description -->
|
||||
<div class="mt-2 text-xs text-gray-600 text-left">
|
||||
{{ role.description }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="addTeamMemberForm.recentlySuccessful" class="mr-3">
|
||||
Added.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': addTeamMemberForm.processing }" :disabled="addTeamMemberForm.processing">
|
||||
Add
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
<div v-if="team.team_invitations.length > 0 && userPermissions.canAddTeamMembers">
|
||||
<SectionBorder />
|
||||
|
||||
<!-- Team Member Invitations -->
|
||||
<ActionSection class="mt-10 sm:mt-0">
|
||||
<template #title>
|
||||
Pending Team Invitations
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
These people have been invited to your team and have been sent an invitation email. They may join the team by accepting the email invitation.
|
||||
</template>
|
||||
|
||||
<!-- Pending Team Member Invitation List -->
|
||||
<template #content>
|
||||
<div class="space-y-6">
|
||||
<div v-for="invitation in team.team_invitations" :key="invitation.id" class="flex items-center justify-between">
|
||||
<div class="text-gray-600">
|
||||
{{ invitation.email }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<!-- Cancel Team Invitation -->
|
||||
<button
|
||||
v-if="userPermissions.canRemoveTeamMembers"
|
||||
class="cursor-pointer ml-6 text-sm text-red-500 focus:outline-none"
|
||||
@click="cancelTeamInvitation(invitation)"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</div>
|
||||
|
||||
<div v-if="team.users.length > 0">
|
||||
<SectionBorder />
|
||||
|
||||
<!-- Manage Team Members -->
|
||||
<ActionSection class="mt-10 sm:mt-0">
|
||||
<template #title>
|
||||
Team Members
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
All of the people that are part of this team.
|
||||
</template>
|
||||
|
||||
<!-- Team Member List -->
|
||||
<template #content>
|
||||
<div class="space-y-6">
|
||||
<div v-for="user in team.users" :key="user.id" class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<img class="w-8 h-8 rounded-full" :src="user.profile_photo_url" :alt="user.name">
|
||||
<div class="ml-4">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<!-- Manage Team Member Role -->
|
||||
<button
|
||||
v-if="userPermissions.canAddTeamMembers && availableRoles.length"
|
||||
class="ml-2 text-sm text-gray-400 underline"
|
||||
@click="manageRole(user)"
|
||||
>
|
||||
{{ displayableRole(user.membership.role) }}
|
||||
</button>
|
||||
|
||||
<div v-else-if="availableRoles.length" class="ml-2 text-sm text-gray-400">
|
||||
{{ displayableRole(user.membership.role) }}
|
||||
</div>
|
||||
|
||||
<!-- Leave Team -->
|
||||
<button
|
||||
v-if="$page.props.user.id === user.id"
|
||||
class="cursor-pointer ml-6 text-sm text-red-500"
|
||||
@click="confirmLeavingTeam"
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
|
||||
<!-- Remove Team Member -->
|
||||
<button
|
||||
v-else-if="userPermissions.canRemoveTeamMembers"
|
||||
class="cursor-pointer ml-6 text-sm text-red-500"
|
||||
@click="confirmTeamMemberRemoval(user)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</div>
|
||||
|
||||
<!-- Role Management Modal -->
|
||||
<DialogModal :show="currentlyManagingRole" @close="currentlyManagingRole = false">
|
||||
<template #title>
|
||||
Manage Role
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="managingRoleFor">
|
||||
<div class="relative z-0 mt-1 border border-gray-200 rounded-lg cursor-pointer">
|
||||
<button
|
||||
v-for="(role, i) in availableRoles"
|
||||
:key="role.key"
|
||||
type="button"
|
||||
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-blue-300 focus:ring focus:ring-blue-200"
|
||||
:class="{'border-t border-gray-200 rounded-t-none': i > 0, 'rounded-b-none': i !== Object.keys(availableRoles).length - 1}"
|
||||
@click="updateRoleForm.role = role.key"
|
||||
>
|
||||
<div :class="{'opacity-50': updateRoleForm.role && updateRoleForm.role !== role.key}">
|
||||
<!-- Role Name -->
|
||||
<div class="flex items-center">
|
||||
<div class="text-sm text-gray-600" :class="{'font-semibold': updateRoleForm.role === role.key}">
|
||||
{{ role.name }}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
v-if="updateRoleForm.role === role.key"
|
||||
class="ml-2 h-5 w-5 text-green-400"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
</div>
|
||||
|
||||
<!-- Role Description -->
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
{{ role.description }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="currentlyManagingRole = false">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ml-3"
|
||||
:class="{ 'opacity-25': updateRoleForm.processing }"
|
||||
:disabled="updateRoleForm.processing"
|
||||
@click="updateRole"
|
||||
>
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
<!-- Leave Team Confirmation Modal -->
|
||||
<ConfirmationModal :show="confirmingLeavingTeam" @close="confirmingLeavingTeam = false">
|
||||
<template #title>
|
||||
Leave Team
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you would like to leave this team?
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="confirmingLeavingTeam = false">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ml-3"
|
||||
:class="{ 'opacity-25': leaveTeamForm.processing }"
|
||||
:disabled="leaveTeamForm.processing"
|
||||
@click="leaveTeam"
|
||||
>
|
||||
Leave
|
||||
</DangerButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
|
||||
<!-- Remove Team Member Confirmation Modal -->
|
||||
<ConfirmationModal :show="teamMemberBeingRemoved" @close="teamMemberBeingRemoved = null">
|
||||
<template #title>
|
||||
Remove Team Member
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
Are you sure you would like to remove this person from the team?
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="teamMemberBeingRemoved = null">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ml-3"
|
||||
:class="{ 'opacity-25': removeTeamMemberForm.processing }"
|
||||
:disabled="removeTeamMemberForm.processing"
|
||||
@click="removeTeamMember"
|
||||
>
|
||||
Remove
|
||||
</DangerButton>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
</template>
|
80
resources/js/Pages/Teams/Partials/UpdateTeamNameForm.vue
Normal file
80
resources/js/Pages/Teams/Partials/UpdateTeamNameForm.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/inertia-vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
team: Object,
|
||||
permissions: Object,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
name: props.team.name,
|
||||
});
|
||||
|
||||
const updateTeamName = () => {
|
||||
form.put(route('teams.update', props.team), {
|
||||
errorBag: 'updateTeamName',
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="updateTeamName">
|
||||
<template #title>
|
||||
Team Name
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
The team's name and owner information.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<!-- Team Owner Information -->
|
||||
<div class="col-span-6">
|
||||
<InputLabel value="Team Owner" />
|
||||
|
||||
<div class="flex items-center mt-2">
|
||||
<img class="w-12 h-12 rounded-full object-cover" :src="team.owner.profile_photo_url" :alt="team.owner.name">
|
||||
|
||||
<div class="ml-4 leading-tight">
|
||||
<div>{{ team.owner.name }}</div>
|
||||
<div class="text-gray-700 text-sm">
|
||||
{{ team.owner.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Name -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="name" value="Team Name" />
|
||||
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
:disabled="! permissions.canUpdateTeam"
|
||||
/>
|
||||
|
||||
<InputError :message="form.errors.name" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="permissions.canUpdateTeam" #actions>
|
||||
<ActionMessage :on="form.recentlySuccessful" class="mr-3">
|
||||
Saved.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
42
resources/js/Pages/Teams/Show.vue
Normal file
42
resources/js/Pages/Teams/Show.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DeleteTeamForm from '@/Pages/Teams/Partials/DeleteTeamForm.vue';
|
||||
import SectionBorder from '@/Components/SectionBorder.vue';
|
||||
import TeamMemberManager from '@/Pages/Teams/Partials/TeamMemberManager.vue';
|
||||
import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue';
|
||||
|
||||
defineProps({
|
||||
team: Object,
|
||||
availableRoles: Array,
|
||||
permissions: Object,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Team Settings">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Team Settings
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||
|
||||
<TeamMemberManager
|
||||
class="mt-10 sm:mt-0"
|
||||
:team="team"
|
||||
:available-roles="availableRoles"
|
||||
:user-permissions="permissions"
|
||||
/>
|
||||
|
||||
<template v-if="permissions.canDeleteTeam && ! team.personal_team">
|
||||
<SectionBorder />
|
||||
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
24
resources/js/Pages/TermsOfService.vue
Normal file
24
resources/js/Pages/TermsOfService.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { Head } from '@inertiajs/inertia-vue3';
|
||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||
|
||||
defineProps({
|
||||
terms: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Terms of Service" />
|
||||
|
||||
<div class="font-sans text-gray-900 antialiased">
|
||||
<div class="pt-4 bg-gray-100">
|
||||
<div class="min-h-screen flex flex-col items-center pt-6 sm:pt-0">
|
||||
<div>
|
||||
<AuthenticationCardLogo />
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:max-w-2xl mt-6 p-6 bg-white shadow-md overflow-hidden sm:rounded-lg prose" v-html="terms" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
179
resources/js/Pages/Welcome.vue
Normal file
179
resources/js/Pages/Welcome.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
import { Head, Link } from '@inertiajs/inertia-vue3';
|
||||
|
||||
defineProps({
|
||||
canLogin: Boolean,
|
||||
canRegister: Boolean,
|
||||
laravelVersion: String,
|
||||
phpVersion: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Welcome" />
|
||||
|
||||
<div class="relative flex items-top justify-center min-h-screen bg-gray-100 dark:bg-gray-900 sm:items-center sm:pt-0">
|
||||
<div v-if="canLogin" class="hidden fixed top-0 right-0 px-6 py-4 sm:block">
|
||||
<Link v-if="$page.props.user" :href="route('dashboard')" class="text-sm text-gray-700 dark:text-gray-500 underline">Dashboard</Link>
|
||||
|
||||
<template v-else>
|
||||
<Link :href="route('login')" class="text-sm text-gray-700 dark:text-gray-500 underline">Log in</Link>
|
||||
|
||||
<Link v-if="canRegister" :href="route('register')" class="ml-4 text-sm text-gray-700 dark:text-gray-500 underline">Register</Link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="flex justify-center pt-8 sm:justify-start sm:pt-0">
|
||||
<svg
|
||||
viewBox="0 0 651 192"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-auto text-gray-700 sm:h-20"
|
||||
>
|
||||
<g clip-path="url(#clip0)" fill="#EF3B2D">
|
||||
<path d="M248.032 44.676h-16.466v100.23h47.394v-14.748h-30.928V44.676zM337.091 87.202c-2.101-3.341-5.083-5.965-8.949-7.875-3.865-1.909-7.756-2.864-11.669-2.864-5.062 0-9.69.931-13.89 2.792-4.201 1.861-7.804 4.417-10.811 7.661-3.007 3.246-5.347 6.993-7.016 11.239-1.672 4.249-2.506 8.713-2.506 13.389 0 4.774.834 9.26 2.506 13.459 1.669 4.202 4.009 7.925 7.016 11.169 3.007 3.246 6.609 5.799 10.811 7.66 4.199 1.861 8.828 2.792 13.89 2.792 3.913 0 7.804-.955 11.669-2.863 3.866-1.908 6.849-4.533 8.949-7.875v9.021h15.607V78.182h-15.607v9.02zm-1.431 32.503c-.955 2.578-2.291 4.821-4.009 6.73-1.719 1.91-3.795 3.437-6.229 4.582-2.435 1.146-5.133 1.718-8.091 1.718-2.96 0-5.633-.572-8.019-1.718-2.387-1.146-4.438-2.672-6.156-4.582-1.719-1.909-3.032-4.152-3.938-6.73-.909-2.577-1.36-5.298-1.36-8.161 0-2.864.451-5.585 1.36-8.162.905-2.577 2.219-4.819 3.938-6.729 1.718-1.908 3.77-3.437 6.156-4.582 2.386-1.146 5.059-1.718 8.019-1.718 2.958 0 5.656.572 8.091 1.718 2.434 1.146 4.51 2.674 6.229 4.582 1.718 1.91 3.054 4.152 4.009 6.729.953 2.577 1.432 5.298 1.432 8.162-.001 2.863-.479 5.584-1.432 8.161zM463.954 87.202c-2.101-3.341-5.083-5.965-8.949-7.875-3.865-1.909-7.756-2.864-11.669-2.864-5.062 0-9.69.931-13.89 2.792-4.201 1.861-7.804 4.417-10.811 7.661-3.007 3.246-5.347 6.993-7.016 11.239-1.672 4.249-2.506 8.713-2.506 13.389 0 4.774.834 9.26 2.506 13.459 1.669 4.202 4.009 7.925 7.016 11.169 3.007 3.246 6.609 5.799 10.811 7.66 4.199 1.861 8.828 2.792 13.89 2.792 3.913 0 7.804-.955 11.669-2.863 3.866-1.908 6.849-4.533 8.949-7.875v9.021h15.607V78.182h-15.607v9.02zm-1.432 32.503c-.955 2.578-2.291 4.821-4.009 6.73-1.719 1.91-3.795 3.437-6.229 4.582-2.435 1.146-5.133 1.718-8.091 1.718-2.96 0-5.633-.572-8.019-1.718-2.387-1.146-4.438-2.672-6.156-4.582-1.719-1.909-3.032-4.152-3.938-6.73-.909-2.577-1.36-5.298-1.36-8.161 0-2.864.451-5.585 1.36-8.162.905-2.577 2.219-4.819 3.938-6.729 1.718-1.908 3.77-3.437 6.156-4.582 2.386-1.146 5.059-1.718 8.019-1.718 2.958 0 5.656.572 8.091 1.718 2.434 1.146 4.51 2.674 6.229 4.582 1.718 1.91 3.054 4.152 4.009 6.729.953 2.577 1.432 5.298 1.432 8.162 0 2.863-.479 5.584-1.432 8.161zM650.772 44.676h-15.606v100.23h15.606V44.676zM365.013 144.906h15.607V93.538h26.776V78.182h-42.383v66.724zM542.133 78.182l-19.616 51.096-19.616-51.096h-15.808l25.617 66.724h19.614l25.617-66.724h-15.808zM591.98 76.466c-19.112 0-34.239 15.706-34.239 35.079 0 21.416 14.641 35.079 36.239 35.079 12.088 0 19.806-4.622 29.234-14.688l-10.544-8.158c-.006.008-7.958 10.449-19.832 10.449-13.802 0-19.612-11.127-19.612-16.884h51.777c2.72-22.043-11.772-40.877-33.023-40.877zm-18.713 29.28c.12-1.284 1.917-16.884 18.589-16.884 16.671 0 18.697 15.598 18.813 16.884h-37.402zM184.068 43.892c-.024-.088-.073-.165-.104-.25-.058-.157-.108-.316-.191-.46-.056-.097-.137-.176-.203-.265-.087-.117-.161-.242-.265-.345-.085-.086-.194-.148-.29-.223-.109-.085-.206-.182-.327-.252l-.002-.001-.002-.002-35.648-20.524a2.971 2.971 0 00-2.964 0l-35.647 20.522-.002.002-.002.001c-.121.07-.219.167-.327.252-.096.075-.205.138-.29.223-.103.103-.178.228-.265.345-.066.089-.147.169-.203.265-.083.144-.133.304-.191.46-.031.085-.08.162-.104.25-.067.249-.103.51-.103.776v38.979l-29.706 17.103V24.493a3 3 0 00-.103-.776c-.024-.088-.073-.165-.104-.25-.058-.157-.108-.316-.191-.46-.056-.097-.137-.176-.203-.265-.087-.117-.161-.242-.265-.345-.085-.086-.194-.148-.29-.223-.109-.085-.206-.182-.327-.252l-.002-.001-.002-.002L40.098 1.396a2.971 2.971 0 00-2.964 0L1.487 21.919l-.002.002-.002.001c-.121.07-.219.167-.327.252-.096.075-.205.138-.29.223-.103.103-.178.228-.265.345-.066.089-.147.169-.203.265-.083.144-.133.304-.191.46-.031.085-.08.162-.104.25-.067.249-.103.51-.103.776v122.09c0 1.063.568 2.044 1.489 2.575l71.293 41.045c.156.089.324.143.49.202.078.028.15.074.23.095a2.98 2.98 0 001.524 0c.069-.018.132-.059.2-.083.176-.061.354-.119.519-.214l71.293-41.045a2.971 2.971 0 001.489-2.575v-38.979l34.158-19.666a2.971 2.971 0 001.489-2.575V44.666a3.075 3.075 0 00-.106-.774zM74.255 143.167l-29.648-16.779 31.136-17.926.001-.001 34.164-19.669 29.674 17.084-21.772 12.428-43.555 24.863zm68.329-76.259v33.841l-12.475-7.182-17.231-9.92V49.806l12.475 7.182 17.231 9.92zm2.97-39.335l29.693 17.095-29.693 17.095-29.693-17.095 29.693-17.095zM54.06 114.089l-12.475 7.182V46.733l17.231-9.92 12.475-7.182v74.537l-17.231 9.921zM38.614 7.398l29.693 17.095-29.693 17.095L8.921 24.493 38.614 7.398zM5.938 29.632l12.475 7.182 17.231 9.92v79.676l.001.005-.001.006c0 .114.032.221.045.333.017.146.021.294.059.434l.002.007c.032.117.094.222.14.334.051.124.088.255.156.371a.036.036 0 00.004.009c.061.105.149.191.222.288.081.105.149.22.244.314l.008.01c.084.083.19.142.284.215.106.083.202.178.32.247l.013.005.011.008 34.139 19.321v34.175L5.939 144.867V29.632h-.001zm136.646 115.235l-65.352 37.625V148.31l48.399-27.628 16.953-9.677v33.862zm35.646-61.22l-29.706 17.102V66.908l17.231-9.92 12.475-7.182v33.841z" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-white dark:bg-gray-800 overflow-hidden shadow sm:rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-8 h-8 text-gray-500"
|
||||
><path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>
|
||||
<div class="ml-4 text-lg leading-7 font-semibold">
|
||||
<a href="https://laravel.com/docs" class="underline text-gray-900 dark:text-white">Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-12">
|
||||
<div class="mt-2 text-gray-600 dark:text-gray-400 text-sm">
|
||||
Laravel has wonderful, thorough documentation covering every aspect of the framework. Whether you are new to the framework or have previous experience with Laravel, we recommend reading all of the documentation from beginning to end.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 md:border-t-0 md:border-l">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-8 h-8 text-gray-500"
|
||||
><path d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
<div class="ml-4 text-lg leading-7 font-semibold">
|
||||
<a href="https://laracasts.com" class="underline text-gray-900 dark:text-white">Laracasts</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-12">
|
||||
<div class="mt-2 text-gray-600 dark:text-gray-400 text-sm">
|
||||
Laracasts offers thousands of video tutorials on Laravel, PHP, and JavaScript development. Check them out, see for yourself, and massively level up your development skills in the process.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-8 h-8 text-gray-500"
|
||||
><path d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" /></svg>
|
||||
<div class="ml-4 text-lg leading-7 font-semibold">
|
||||
<a href="https://laravel-news.com/" class="underline text-gray-900 dark:text-white">Laravel News</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-12">
|
||||
<div class="mt-2 text-gray-600 dark:text-gray-400 text-sm">
|
||||
Laravel News is a community driven portal and newsletter aggregating all of the latest and most important news in the Laravel ecosystem, including new package releases and tutorials.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 md:border-l">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-8 h-8 text-gray-500"
|
||||
><path d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<div class="ml-4 text-lg leading-7 font-semibold text-gray-900 dark:text-white">
|
||||
Vibrant Ecosystem
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-12">
|
||||
<div class="mt-2 text-gray-600 dark:text-gray-400 text-sm">
|
||||
Laravel's robust library of first-party tools and libraries, such as <a href="https://forge.laravel.com" class="underline">Forge</a>, <a href="https://vapor.laravel.com" class="underline">Vapor</a>, <a href="https://nova.laravel.com" class="underline">Nova</a>, and <a href="https://envoyer.io" class="underline">Envoyer</a> help you take your projects to the next level. Pair them with powerful open source libraries like <a href="https://laravel.com/docs/billing" class="underline">Cashier</a>, <a href="https://laravel.com/docs/dusk" class="underline">Dusk</a>, <a href="https://laravel.com/docs/broadcasting" class="underline">Echo</a>, <a href="https://laravel.com/docs/horizon" class="underline">Horizon</a>, <a href="https://laravel.com/docs/sanctum" class="underline">Sanctum</a>, <a href="https://laravel.com/docs/telescope" class="underline">Telescope</a>, and more.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-4 sm:items-center sm:justify-between">
|
||||
<div class="text-center text-sm text-gray-500 sm:text-left">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
class="-mt-px w-5 h-5 text-gray-400"
|
||||
>
|
||||
<path d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
|
||||
<a href="https://laravel.bigcartel.com" class="ml-1 underline">
|
||||
Shop
|
||||
</a>
|
||||
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
class="ml-4 -mt-px w-5 h-5 text-gray-400"
|
||||
>
|
||||
<path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
|
||||
<a href="https://github.com/sponsors/taylorotwell" class="ml-1 underline">
|
||||
Sponsor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4 text-center text-sm text-gray-500 sm:text-right sm:ml-0">
|
||||
Laravel v{{ laravelVersion }} (PHP v{{ phpVersion }})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
Reference in New Issue
Block a user