Compare commits
3 Commits
892d14c13a
...
637cc4a3d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637cc4a3d0 | ||
|
|
e66cea1d12 | ||
|
|
b99876fb4c |
80
.agents/skills/socialite-development/SKILL.md
Normal file
80
.agents/skills/socialite-development/SKILL.md
Normal file
@ -0,0 +1,80 @@
|
||||
---
|
||||
name: socialite-development
|
||||
description: "Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Socialite Authentication
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Socialite patterns and documentation (installation, configuration, routing, callbacks, testing, scopes, stateless auth).
|
||||
|
||||
## Available Providers
|
||||
|
||||
Built-in: `facebook`, `twitter`, `twitter-oauth-2`, `linkedin`, `linkedin-openid`, `google`, `github`, `gitlab`, `bitbucket`, `slack`, `slack-openid`, `twitch`
|
||||
|
||||
Community: 150+ additional providers at [socialiteproviders.com](https://socialiteproviders.com). For provider-specific setup, use `WebFetch` on `https://socialiteproviders.com/{provider-name}`.
|
||||
|
||||
Configuration key in `config/services.php` must match the driver name exactly — note the hyphenated keys: `twitter-oauth-2`, `linkedin-openid`, `slack-openid`.
|
||||
|
||||
Twitter/X: Use `twitter-oauth-2` (OAuth 2.0) for new projects. The legacy `twitter` driver is OAuth 1.0. Driver names remain unchanged despite the platform rebrand.
|
||||
|
||||
Community providers differ from built-in providers in the following ways:
|
||||
- Installed via `composer require socialiteproviders/{name}`
|
||||
- Must register via event listener — NOT auto-discovered like built-in providers
|
||||
- Use `search-docs` for the registration pattern
|
||||
|
||||
## Adding a Provider
|
||||
|
||||
### 1. Configure the provider
|
||||
|
||||
Add the provider's `client_id`, `client_secret`, and `redirect` to `config/services.php`. The config key must match the driver name exactly.
|
||||
|
||||
### 2. Create redirect and callback routes
|
||||
|
||||
Two routes are needed: one that calls `Socialite::driver('provider')->redirect()` to send the user to the OAuth provider, and one that calls `Socialite::driver('provider')->user()` to receive the callback and retrieve user details.
|
||||
|
||||
### 3. Authenticate and store the user
|
||||
|
||||
In the callback, use `updateOrCreate` to find or create a user record from the provider's response (`id`, `name`, `email`, `token`, `refreshToken`), then call `Auth::login()`.
|
||||
|
||||
### 4. Customize the redirect (optional)
|
||||
|
||||
- `scopes()` — merge additional scopes with the provider's defaults
|
||||
- `setScopes()` — replace all scopes entirely
|
||||
- `with()` — pass optional parameters (e.g., `['hd' => 'example.com']` for Google)
|
||||
- `asBotUser()` — Slack only; generates a bot token (`xoxb-`) instead of a user token (`xoxp-`). Must be called before both `redirect()` and `user()`. Only the `token` property will be hydrated on the user object.
|
||||
- `stateless()` — for API/SPA contexts where session state is not maintained
|
||||
|
||||
### 5. Verify
|
||||
|
||||
1. Config key matches driver name exactly (check the list above for hyphenated names)
|
||||
2. `client_id`, `client_secret`, and `redirect` are all present
|
||||
3. Redirect URL matches what is registered in the provider's OAuth dashboard
|
||||
4. Callback route handles denied grants (when user declines authorization)
|
||||
|
||||
Use `search-docs` for complete code examples of each step.
|
||||
|
||||
## Additional Features
|
||||
|
||||
Use `search-docs` for usage details on: `enablePKCE()`, `userFromToken($token)`, `userFromTokenAndSecret($token, $secret)` (OAuth 1.0), retrieving user details.
|
||||
|
||||
User object: `getId()`, `getName()`, `getEmail()`, `getAvatar()`, `getNickname()`, `token`, `refreshToken`, `expiresIn`, `approvedScopes`
|
||||
|
||||
## Testing
|
||||
|
||||
Socialite provides `Socialite::fake()` for testing redirects and callbacks. Use `search-docs` for faking redirects, callback user data, custom token properties, and assertion methods.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Config key must match driver name exactly — hyphenated drivers need hyphenated keys (`linkedin-openid`, `slack-openid`, `twitter-oauth-2`). Mismatch silently fails.
|
||||
- Every provider needs `client_id`, `client_secret`, and `redirect` in `config/services.php`. Missing any one causes cryptic errors.
|
||||
- `scopes()` merges with defaults; `setScopes()` replaces all scopes entirely.
|
||||
- Missing `stateless()` in API/SPA contexts causes `InvalidStateException`.
|
||||
- Redirect URL in `config/services.php` must exactly match the provider's OAuth dashboard (including trailing slashes and protocol).
|
||||
- Do not pass `state`, `response_type`, `client_id`, `redirect_uri`, or `scope` via `with()` — these are reserved.
|
||||
- Community providers require event listener registration via `SocialiteWasCalled`.
|
||||
- `user()` throws when the user declines authorization. Always handle denied grants.
|
||||
@ -8,6 +8,10 @@ APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI="${APP_URL}/auth/google/callback"
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@ -26,10 +26,8 @@ jobs:
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
|
||||
- name: Setup Application
|
||||
run: composer setup
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -19,8 +19,7 @@
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
bun-debug.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
|
||||
@ -14,6 +14,7 @@ ## Foundational Context
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/socialite (SOCIALITE) - v5
|
||||
- laravel/wayfinder (WAYFINDER) - v0
|
||||
- larastan/larastan (LARASTAN) - v3
|
||||
- laravel/boost (BOOST) - v2
|
||||
@ -51,7 +52,7 @@ ## Application Structure & Architecture
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `bun run build`, `bun run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@ -174,7 +175,7 @@ ## Testing
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `bun run build` or ask the user to run `bun run dev` or `composer run dev`.
|
||||
|
||||
=== wayfinder/core rules ===
|
||||
|
||||
|
||||
@ -23,15 +23,27 @@ public function __construct(
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
$academicTermSelected = $request->has('academic_term_id');
|
||||
$academicTermId = $academicTermSelected
|
||||
? ((int) $request->validated('academic_term_id') ?: null)
|
||||
: $this->academicTermService->getActive()?->id;
|
||||
|
||||
return Inertia::render('admin/academic-classes/assignments/index', [
|
||||
'assignments' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
$request->user(),
|
||||
...$request->validatedWithDefaults(),
|
||||
courseClassId: $request->validated('course_class_id'),
|
||||
academicTermId: $this->academicTermService->getActive()?->id,
|
||||
academicTermId: $academicTermId,
|
||||
)),
|
||||
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
||||
'filters' => $request->only(['course_class_id']),
|
||||
'filterCourseClasses' => Inertia::always($this->courseClassService->getAllForSelectByAcademicTerm($request->user(), $academicTermId)),
|
||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
||||
'filters' => Inertia::always([
|
||||
'course_class_id' => $request->validated('course_class_id'),
|
||||
'academic_term_id' => $academicTermSelected
|
||||
? (string) ($academicTermId ?? 0)
|
||||
: ($academicTermId ? (string) $academicTermId : null),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -23,15 +23,27 @@ public function __construct(
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
$academicTermSelected = $request->has('academic_term_id');
|
||||
$academicTermId = $academicTermSelected
|
||||
? ((int) $request->validated('academic_term_id') ?: null)
|
||||
: $this->academicTermService->getActive()?->id;
|
||||
|
||||
return Inertia::render('admin/academic-classes/materials/index', [
|
||||
'materials' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
$request->user(),
|
||||
...$request->validatedWithDefaults(),
|
||||
courseClassId: $request->validated('course_class_id'),
|
||||
academicTermId: $this->academicTermService->getActive()?->id,
|
||||
academicTermId: $academicTermId,
|
||||
)),
|
||||
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
||||
'filters' => $request->only(['course_class_id']),
|
||||
'filterCourseClasses' => Inertia::always($this->courseClassService->getAllForSelectByAcademicTerm($request->user(), $academicTermId)),
|
||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
||||
'filters' => Inertia::always([
|
||||
'course_class_id' => $request->validated('course_class_id'),
|
||||
'academic_term_id' => $academicTermSelected
|
||||
? (string) ($academicTermId ?? 0)
|
||||
: ($academicTermId ? (string) $academicTermId : null),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
56
app/Http/Controllers/Auth/GoogleAuthController.php
Normal file
56
app/Http/Controllers/Auth/GoogleAuthController.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
|
||||
|
||||
class GoogleAuthController extends Controller
|
||||
{
|
||||
public function redirect(): SymfonyRedirectResponse
|
||||
{
|
||||
return Socialite::driver('google')->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$googleUser = Socialite::driver('google')->user();
|
||||
|
||||
$user = User::where('email', $googleUser->getEmail())->first();
|
||||
|
||||
if (! $user) {
|
||||
Inertia::flash('toast', [
|
||||
'type' => 'error',
|
||||
'message' => 'Akun dengan email Google tersebut tidak ditemukan. Silakan hubungi administrator.',
|
||||
]);
|
||||
|
||||
return Redirect::route('login');
|
||||
}
|
||||
|
||||
if (! $user->is_active) {
|
||||
Inertia::flash('toast', [
|
||||
'type' => 'error',
|
||||
'message' => 'Akun Anda tidak aktif. Silakan hubungi administrator.',
|
||||
]);
|
||||
|
||||
return Redirect::route('login');
|
||||
}
|
||||
|
||||
if ($user->google_id !== $googleUser->getId()) {
|
||||
$user->update(['google_id' => $googleUser->getId()]);
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return Redirect::intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@ -17,8 +17,8 @@
|
||||
use Laravel\Passkeys\PasskeyAuthenticatable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
#[Hidden(['password'])]
|
||||
#[Guarded(['id', 'last_login_at'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
#[Guarded(['id', 'last_login_at', 'remember_token'])]
|
||||
#[Appends(['full_name'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\Master\AcademicTermService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@ -116,8 +117,29 @@ public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademic
|
||||
public function getAllForSelect(User $user): Collection
|
||||
{
|
||||
$isLecturer = $user->hasRole(UserRole::Dosen->value);
|
||||
$activeAcademicTermId = $isLecturer ? $this->academicTermService->getActive()?->id : null;
|
||||
$academicTermId = $isLecturer ? $this->academicTermService->getActive()?->id : null;
|
||||
|
||||
return $this->queryForSelect($user, $academicTermId)
|
||||
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly scoped to a single academic term, e.g. for a filter that
|
||||
* should only offer classes once a specific term has been chosen.
|
||||
* Returns an empty collection when no term is given.
|
||||
*/
|
||||
public function getAllForSelectByAcademicTerm(User $user, ?int $academicTermId): Collection
|
||||
{
|
||||
if (! $academicTermId) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
return $this->queryForSelect($user, $academicTermId)
|
||||
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
|
||||
}
|
||||
|
||||
private function queryForSelect(User $user, ?int $academicTermId): Builder
|
||||
{
|
||||
return CourseClass::query()
|
||||
->join('courses', 'courses.id', '=', 'course_classes.course_id')
|
||||
->join('departments', 'departments.id', '=', 'courses.department_id')
|
||||
@ -125,12 +147,11 @@ public function getAllForSelect(User $user): Collection
|
||||
'course:id,code,name,semester_number,department_id',
|
||||
'course.department:id,name',
|
||||
])
|
||||
->when($isLecturer, fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id)
|
||||
->where('course_classes.academic_term_id', $activeAcademicTermId))
|
||||
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id))
|
||||
->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId))
|
||||
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
|
||||
->orderBy('departments.name')
|
||||
->orderBy('courses.semester_number')
|
||||
->orderBy('courses.name')
|
||||
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
|
||||
->orderBy('courses.name');
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"skills": [
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"socialite-development",
|
||||
"wayfinder-development",
|
||||
"pest-testing",
|
||||
"inertia-react-development",
|
||||
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[install]
|
||||
ignoreScripts = true
|
||||
@ -14,6 +14,7 @@
|
||||
"laravel/chisel": "^0.1.0",
|
||||
"laravel/fortify": "^1.37.2",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/socialite": "^5.31",
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.14",
|
||||
"maatwebsite/excel": "^4.0",
|
||||
@ -52,8 +53,8 @@
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
"bun install",
|
||||
"bun run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
@ -67,9 +68,9 @@
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm run lint:check",
|
||||
"npm run format:check",
|
||||
"npm run types:check",
|
||||
"bun run lint:check",
|
||||
"bun run format:check",
|
||||
"bun run types:check",
|
||||
"@test"
|
||||
],
|
||||
"types:check": [
|
||||
|
||||
416
composer.lock
generated
416
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "379c97a9e3b22c91d1427f44e711a975",
|
||||
"content-hash": "96c16aa645077a07df0727ce7f38e195",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@ -813,6 +813,72 @@
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "firebase/php-jwt",
|
||||
"version": "v7.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/googleapis/php-jwt.git",
|
||||
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
|
||||
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"phpfastcache/phpfastcache": "^9.2",
|
||||
"phpseclib/phpseclib": "~3.0",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"psr/cache": "^2.0||^3.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
||||
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
|
||||
"phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Firebase\\JWT\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Neuman Vong",
|
||||
"email": "neuman+pear@twilio.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Anant Narayanan",
|
||||
"email": "anant@php.net",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
||||
"homepage": "https://github.com/googleapis/php-jwt",
|
||||
"keywords": [
|
||||
"jwt",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/googleapis/php-jwt/issues",
|
||||
"source": "https://github.com/googleapis/php-jwt/tree/v7.1.0"
|
||||
},
|
||||
"time": "2026-06-11T17:54:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "fruitcake/php-cors",
|
||||
"version": "v1.4.0",
|
||||
@ -1966,6 +2032,78 @@
|
||||
},
|
||||
"time": "2026-07-21T16:49:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/socialite",
|
||||
"version": "v5.31.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/socialite.git",
|
||||
"reference": "f721b2cbec327ab820bd6aabea6ab211cfcc9f08"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/socialite/zipball/f721b2cbec327ab820bd6aabea6ab211cfcc9f08",
|
||||
"reference": "f721b2cbec327ab820bd6aabea6ab211cfcc9f08",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"firebase/php-jwt": "^6.4|^7.0",
|
||||
"guzzlehttp/guzzle": "^6.0|^7.0|^8.0",
|
||||
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"league/oauth1-client": "^1.11",
|
||||
"php": "^8.1",
|
||||
"phpseclib/phpseclib": "^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.0",
|
||||
"orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0",
|
||||
"phpstan/phpstan": "^1.12.23",
|
||||
"phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Socialite": "Laravel\\Socialite\\Facades\\Socialite"
|
||||
},
|
||||
"providers": [
|
||||
"Laravel\\Socialite\\SocialiteServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Socialite\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.",
|
||||
"homepage": "https://laravel.com",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"oauth"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/socialite/issues",
|
||||
"source": "https://github.com/laravel/socialite"
|
||||
},
|
||||
"time": "2026-08-31T13:49:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/tinker",
|
||||
"version": "v3.0.2",
|
||||
@ -2475,6 +2613,82 @@
|
||||
],
|
||||
"time": "2026-07-09T11:49:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/oauth1-client",
|
||||
"version": "v1.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/oauth1-client.git",
|
||||
"reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055",
|
||||
"reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||
"guzzlehttp/psr7": "^1.7|^2.0",
|
||||
"php": ">=7.1||>=8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-simplexml": "*",
|
||||
"friendsofphp/php-cs-fixer": "^2.17",
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpstan/phpstan": "^0.12.42",
|
||||
"phpunit/phpunit": "^7.5||9.5"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-simplexml": "For decoding XML-based responses."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0-dev",
|
||||
"dev-develop": "2.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\OAuth1\\Client\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Corlett",
|
||||
"email": "bencorlett@me.com",
|
||||
"homepage": "http://www.webcomm.com.au",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "OAuth 1.0 Client Library",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"SSO",
|
||||
"authorization",
|
||||
"bitbucket",
|
||||
"identity",
|
||||
"idp",
|
||||
"oauth",
|
||||
"oauth1",
|
||||
"single sign on",
|
||||
"trello",
|
||||
"tumblr",
|
||||
"twitter"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/oauth1-client/issues",
|
||||
"source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0"
|
||||
},
|
||||
"time": "2024-12-10T19:59:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/uri",
|
||||
"version": "7.8.1",
|
||||
@ -3870,6 +4084,126 @@
|
||||
],
|
||||
"time": "2025-12-27T19:41:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpseclib/phpseclib",
|
||||
"version": "4.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpseclib/phpseclib.git",
|
||||
"reference": "bb7b959c8159957edae6f5084ebbac765d310e16"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/bb7b959c8159957edae6f5084ebbac765d310e16",
|
||||
"reference": "bb7b959c8159957edae6f5084ebbac765d310e16",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"paragonie/constant_time_encoding": "^2|^3",
|
||||
"php": ">=8.1",
|
||||
"symfony/polyfill-php82": "^1.26"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.22",
|
||||
"ext-xml": "*",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3",
|
||||
"phpunit/phpunit": "^13",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"vimeo/psalm": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
|
||||
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
|
||||
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
|
||||
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"phpseclib/bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"phpseclib4\\": "phpseclib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jim Wigginton",
|
||||
"email": "terrafrost@php.net",
|
||||
"role": "Lead Developer"
|
||||
},
|
||||
{
|
||||
"name": "Patrick Monnerat",
|
||||
"email": "pm@datasphere.ch",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Andreas Fischer",
|
||||
"email": "bantu@phpbb.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Hans-Jürgen Petrich",
|
||||
"email": "petrich@tronic-media.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "graham@alt-three.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Jack Worman",
|
||||
"email": "jack.worman@gmail.com",
|
||||
"homepage": "https://jackworman.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
|
||||
"homepage": "https://phpseclib.com/",
|
||||
"keywords": [
|
||||
"BigInteger",
|
||||
"aes",
|
||||
"asn.1",
|
||||
"asn1",
|
||||
"blowfish",
|
||||
"crypto",
|
||||
"cryptography",
|
||||
"encryption",
|
||||
"rsa",
|
||||
"security",
|
||||
"sftp",
|
||||
"signature",
|
||||
"signing",
|
||||
"ssh",
|
||||
"twofish",
|
||||
"x.509",
|
||||
"x509"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/phpseclib/phpseclib/issues",
|
||||
"source": "https://github.com/phpseclib/phpseclib/tree/4.0.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/terrafrost",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpseclib",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-26T12:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.3.3",
|
||||
@ -6854,6 +7188,86 @@
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php82",
|
||||
"version": "v1.38.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php82.git",
|
||||
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
|
||||
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Php82\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Resources/stubs"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-26T12:45:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php84",
|
||||
"version": "v1.38.1",
|
||||
|
||||
@ -35,4 +35,10 @@
|
||||
],
|
||||
],
|
||||
|
||||
'google' => [
|
||||
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||
'redirect' => env('GOOGLE_REDIRECT_URI'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->rememberToken();
|
||||
$table->string('google_id')->nullable()->unique()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn(['remember_token', 'google_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
12718
package-lock.json
generated
12718
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
8492
pnpm-lock.yaml
generated
8492
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +0,0 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
publicHoistPattern:
|
||||
- '@inertiajs/core'
|
||||
@ -45,6 +45,7 @@ export type FilterField =
|
||||
placeholder?: string;
|
||||
type?: 'select';
|
||||
options: FilterOption[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
@ -52,6 +53,7 @@ export type FilterField =
|
||||
placeholder?: string;
|
||||
type: 'combobox';
|
||||
groups: FilterOptionGroup[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type FilterDialogProps = {
|
||||
@ -85,10 +87,12 @@ function ComboboxFilterField({
|
||||
isItemEqualToValue={(a: FilterOption, b: FilterOption) =>
|
||||
a.value === b.value
|
||||
}
|
||||
disabled={field.disabled}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={field.placeholder ?? 'Semua'}
|
||||
showClear
|
||||
disabled={field.disabled}
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
@ -190,6 +194,7 @@ export function FilterDialog({
|
||||
onValueChange={(value) =>
|
||||
handleChange(field.key, value)
|
||||
}
|
||||
disabled={field.disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useFlashToast } from '@/hooks/use-flash-toast';
|
||||
import AuthLayoutTemplate from '@/layouts/auth/auth-split-layout';
|
||||
|
||||
export default function AuthLayout({
|
||||
@ -9,6 +10,8 @@ export default function AuthLayout({
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useFlashToast();
|
||||
|
||||
return (
|
||||
<AuthLayoutTemplate title={title} description={description}>
|
||||
{children}
|
||||
|
||||
@ -45,6 +45,7 @@ import {
|
||||
submit,
|
||||
update,
|
||||
} from '@/routes/admin/academic-classes/assignments';
|
||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||
import type { Assignment } from '@/types/assignment';
|
||||
import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
|
||||
import { createAssignmentCard } from './card';
|
||||
@ -63,6 +64,12 @@ type CourseClassOption = {
|
||||
|
||||
type CourseClassGroup = { value: string; items: CourseClassOption[] };
|
||||
|
||||
type AcademicTermOption = {
|
||||
id: number;
|
||||
academic_year: string;
|
||||
semester: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
assignments: {
|
||||
data: Assignment[];
|
||||
@ -72,9 +79,12 @@ type Props = {
|
||||
total: number;
|
||||
};
|
||||
courseClasses: CourseClassOption[];
|
||||
filterCourseClasses: CourseClassOption[];
|
||||
academicTerms: AcademicTermOption[];
|
||||
highlight?: number;
|
||||
filters: {
|
||||
course_class_id?: string;
|
||||
academic_term_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -161,6 +171,8 @@ function CourseClassField({
|
||||
export default function AssignmentIndex({
|
||||
assignments,
|
||||
courseClasses,
|
||||
filterCourseClasses,
|
||||
academicTerms,
|
||||
highlight,
|
||||
filters,
|
||||
}: Props) {
|
||||
@ -177,11 +189,24 @@ export default function AssignmentIndex({
|
||||
const canSubmit = hasPermission('submit-assignments');
|
||||
|
||||
const filterFields = [
|
||||
{
|
||||
key: 'academic_term_id',
|
||||
label: 'Periode Akademik',
|
||||
options: academicTerms.map((term) => ({
|
||||
value: String(term.id),
|
||||
label: formatAcademicTermLabel(term),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'course_class_id',
|
||||
label: 'Kelas',
|
||||
type: 'combobox' as const,
|
||||
groups: courseClassFilterGroups(courseClasses),
|
||||
groups: courseClassFilterGroups(filterCourseClasses),
|
||||
disabled: filterCourseClasses.length === 0,
|
||||
placeholder:
|
||||
filterCourseClasses.length === 0
|
||||
? 'Pilih periode akademik dulu'
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
@ -199,6 +224,42 @@ export default function AssignmentIndex({
|
||||
resetKeys: ['assignments'],
|
||||
});
|
||||
|
||||
function handleApplyFilters(newFilters: Record<string, string>) {
|
||||
// Without an explicit `academic_term_id`, the backend falls back to
|
||||
// the active term, so clearing this filter needs to be sent
|
||||
// explicitly (value '0') rather than just dropping the key.
|
||||
const clearedAcademicTerm =
|
||||
Boolean(filters.academic_term_id) && !newFilters.academic_term_id;
|
||||
|
||||
const nextFilters: Record<string, string> = {
|
||||
...newFilters,
|
||||
...(clearedAcademicTerm ? { academic_term_id: '0' } : {}),
|
||||
};
|
||||
|
||||
// The class list depends on the selected term, so a previously
|
||||
// picked class is no longer relevant once the term changes.
|
||||
const academicTermChanged =
|
||||
(nextFilters.academic_term_id ?? '0') !==
|
||||
(filters.academic_term_id ?? '0');
|
||||
|
||||
if (academicTermChanged) {
|
||||
delete nextFilters.course_class_id;
|
||||
}
|
||||
|
||||
applyFilters(nextFilters);
|
||||
}
|
||||
|
||||
// '0' explicitly means "all terms" — treated as no active filter from
|
||||
// FilterDialog's point of view, so its Select shows "Semua" (not blank)
|
||||
// as selected.
|
||||
const filterDialogActiveFilters = {
|
||||
...filters,
|
||||
academic_term_id:
|
||||
filters.academic_term_id === '0'
|
||||
? undefined
|
||||
: filters.academic_term_id,
|
||||
};
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
@ -233,8 +294,8 @@ export default function AssignmentIndex({
|
||||
<>
|
||||
<FilterDialog
|
||||
fields={filterFields}
|
||||
activeFilters={filters}
|
||||
onApply={applyFilters}
|
||||
activeFilters={filterDialogActiveFilters}
|
||||
onApply={handleApplyFilters}
|
||||
/>
|
||||
<ViewToggle value={view} onChange={setView} />
|
||||
</>
|
||||
|
||||
@ -36,6 +36,7 @@ import {
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/academic-classes/materials';
|
||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||
import type { Material } from '@/types/material';
|
||||
import { createMaterialCard } from './card';
|
||||
import { createMaterialColumns } from './columns';
|
||||
@ -53,6 +54,12 @@ type CourseClassOption = {
|
||||
|
||||
type CourseClassGroup = { value: string; items: CourseClassOption[] };
|
||||
|
||||
type AcademicTermOption = {
|
||||
id: number;
|
||||
academic_year: string;
|
||||
semester: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
materials: {
|
||||
data: Material[];
|
||||
@ -62,9 +69,12 @@ type Props = {
|
||||
total: number;
|
||||
};
|
||||
courseClasses: CourseClassOption[];
|
||||
filterCourseClasses: CourseClassOption[];
|
||||
academicTerms: AcademicTermOption[];
|
||||
highlight?: number;
|
||||
filters: {
|
||||
course_class_id?: string;
|
||||
academic_term_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -151,6 +161,8 @@ function courseClassFilterGroups(
|
||||
export default function MaterialIndex({
|
||||
materials,
|
||||
courseClasses,
|
||||
filterCourseClasses,
|
||||
academicTerms,
|
||||
highlight,
|
||||
filters,
|
||||
}: Props) {
|
||||
@ -164,11 +176,24 @@ export default function MaterialIndex({
|
||||
const canDelete = hasPermission('delete-materials');
|
||||
|
||||
const filterFields = [
|
||||
{
|
||||
key: 'academic_term_id',
|
||||
label: 'Periode Akademik',
|
||||
options: academicTerms.map((term) => ({
|
||||
value: String(term.id),
|
||||
label: formatAcademicTermLabel(term),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'course_class_id',
|
||||
label: 'Kelas',
|
||||
type: 'combobox' as const,
|
||||
groups: courseClassFilterGroups(courseClasses),
|
||||
groups: courseClassFilterGroups(filterCourseClasses),
|
||||
disabled: filterCourseClasses.length === 0,
|
||||
placeholder:
|
||||
filterCourseClasses.length === 0
|
||||
? 'Pilih periode akademik dulu'
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
@ -186,6 +211,39 @@ export default function MaterialIndex({
|
||||
resetKeys: ['materials'],
|
||||
});
|
||||
|
||||
function handleApplyFilters(newFilters: Record<string, string>) {
|
||||
const clearedAcademicTerm =
|
||||
Boolean(filters.academic_term_id) && !newFilters.academic_term_id;
|
||||
|
||||
const nextFilters: Record<string, string> = {
|
||||
...newFilters,
|
||||
...(clearedAcademicTerm ? { academic_term_id: '0' } : {}),
|
||||
};
|
||||
|
||||
// The class list depends on the selected term, so a previously
|
||||
// picked class is no longer relevant once the term changes.
|
||||
const academicTermChanged =
|
||||
(nextFilters.academic_term_id ?? '0') !==
|
||||
(filters.academic_term_id ?? '0');
|
||||
|
||||
if (academicTermChanged) {
|
||||
delete nextFilters.course_class_id;
|
||||
}
|
||||
|
||||
applyFilters(nextFilters);
|
||||
}
|
||||
|
||||
// '0' explicitly means "all terms" — treated as no active filter from
|
||||
// FilterDialog's point of view, so its Select shows "Semua" (not blank)
|
||||
// as selected.
|
||||
const filterDialogActiveFilters = {
|
||||
...filters,
|
||||
academic_term_id:
|
||||
filters.academic_term_id === '0'
|
||||
? undefined
|
||||
: filters.academic_term_id,
|
||||
};
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
@ -214,8 +272,8 @@ export default function MaterialIndex({
|
||||
<>
|
||||
<FilterDialog
|
||||
fields={filterFields}
|
||||
activeFilters={filters}
|
||||
onApply={applyFilters}
|
||||
activeFilters={filterDialogActiveFilters}
|
||||
onApply={handleApplyFilters}
|
||||
/>
|
||||
<ViewToggle value={view} onChange={setView} />
|
||||
</>
|
||||
|
||||
@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import google from '@/routes/auth/google';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
@ -105,29 +106,31 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Masuk dengan Google
|
||||
<a href={google.redirect.url()}>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Masuk dengan Google
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Auth\GoogleAuthController;
|
||||
use App\Http\Controllers\MediaUploadController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::inertia('/', 'welcome')->name('home');
|
||||
|
||||
Route::middleware('guest')->prefix('auth/google')->name('auth.google.')->group(function () {
|
||||
Route::get('redirect', [GoogleAuthController::class, 'redirect'])->name('redirect');
|
||||
Route::get('callback', [GoogleAuthController::class, 'callback'])->name('callback');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::inertia('dashboard', 'dashboard')->name('dashboard');
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user