Compare commits

...

3 Commits

27 changed files with 2665 additions and 21267 deletions

View 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.

View File

@ -8,6 +8,10 @@ APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US 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_DRIVER=file
# APP_MAINTENANCE_STORE=database # APP_MAINTENANCE_STORE=database

View File

@ -26,10 +26,8 @@ jobs:
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
- name: Setup Node - name: Setup Bun
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
node-version: '22'
- name: Setup Application - name: Setup Application
run: composer setup run: composer setup

3
.gitignore vendored
View File

@ -19,8 +19,7 @@
.phpunit.result.cache .phpunit.result.cache
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
npm-debug.log bun-debug.log
yarn-error.log
/auth.json /auth.json
/.fleet /.fleet
/.idea /.idea

1
.npmrc
View File

@ -1 +0,0 @@
ignore-scripts=true

View File

@ -14,6 +14,7 @@ ## Foundational Context
- laravel/fortify (FORTIFY) - v1 - laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13 - laravel/framework (LARAVEL) - v13
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/socialite (SOCIALITE) - v5
- laravel/wayfinder (WAYFINDER) - v0 - laravel/wayfinder (WAYFINDER) - v0
- larastan/larastan (LARASTAN) - v3 - larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2 - laravel/boost (BOOST) - v2
@ -51,7 +52,7 @@ ## Application Structure & Architecture
## Frontend Bundling ## 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 ## Documentation Files
@ -174,7 +175,7 @@ ## Testing
## Vite Error ## 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 === === wayfinder/core rules ===

View File

@ -23,15 +23,27 @@ public function __construct(
public function index(PaginatedRequest $request): Response 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', [ return Inertia::render('admin/academic-classes/assignments/index', [
'assignments' => Inertia::scroll(fn () => $this->service->paginated( 'assignments' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(), $request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
courseClassId: $request->validated('course_class_id'), courseClassId: $request->validated('course_class_id'),
academicTermId: $this->academicTermService->getActive()?->id, academicTermId: $academicTermId,
)), )),
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), '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),
]),
]); ]);
} }

View File

@ -23,15 +23,27 @@ public function __construct(
public function index(PaginatedRequest $request): Response 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', [ return Inertia::render('admin/academic-classes/materials/index', [
'materials' => Inertia::scroll(fn () => $this->service->paginated( 'materials' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(), $request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
courseClassId: $request->validated('course_class_id'), courseClassId: $request->validated('course_class_id'),
academicTermId: $this->academicTermService->getActive()?->id, academicTermId: $academicTermId,
)), )),
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), '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),
]),
]); ]);
} }

View 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));
}
}

View File

@ -17,8 +17,8 @@
use Laravel\Passkeys\PasskeyAuthenticatable; use Laravel\Passkeys\PasskeyAuthenticatable;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
#[Hidden(['password'])] #[Hidden(['password', 'remember_token'])]
#[Guarded(['id', 'last_login_at'])] #[Guarded(['id', 'last_login_at', 'remember_token'])]
#[Appends(['full_name'])] #[Appends(['full_name'])]
class User extends Authenticatable class User extends Authenticatable
{ {

View File

@ -7,6 +7,7 @@
use App\Models\User; use App\Models\User;
use App\Services\Admin\Master\AcademicTermService; use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -116,8 +117,29 @@ public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademic
public function getAllForSelect(User $user): Collection public function getAllForSelect(User $user): Collection
{ {
$isLecturer = $user->hasRole(UserRole::Dosen->value); $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() return CourseClass::query()
->join('courses', 'courses.id', '=', 'course_classes.course_id') ->join('courses', 'courses.id', '=', 'course_classes.course_id')
->join('departments', 'departments.id', '=', 'courses.department_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:id,code,name,semester_number,department_id',
'course.department:id,name', 'course.department:id,name',
]) ])
->when($isLecturer, fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id) ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id))
->where('course_classes.academic_term_id', $activeAcademicTermId)) ->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)) ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
->orderBy('departments.name') ->orderBy('departments.name')
->orderBy('courses.semester_number') ->orderBy('courses.semester_number')
->orderBy('courses.name') ->orderBy('courses.name');
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
} }
} }

View File

@ -10,6 +10,7 @@
"skills": [ "skills": [
"fortify-development", "fortify-development",
"laravel-best-practices", "laravel-best-practices",
"socialite-development",
"wayfinder-development", "wayfinder-development",
"pest-testing", "pest-testing",
"inertia-react-development", "inertia-react-development",

1842
bun.lock Normal file

File diff suppressed because it is too large Load Diff

2
bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[install]
ignoreScripts = true

View File

@ -14,6 +14,7 @@
"laravel/chisel": "^0.1.0", "laravel/chisel": "^0.1.0",
"laravel/fortify": "^1.37.2", "laravel/fortify": "^1.37.2",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",
"laravel/socialite": "^5.31",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14", "laravel/wayfinder": "^0.1.14",
"maatwebsite/excel": "^4.0", "maatwebsite/excel": "^4.0",
@ -52,8 +53,8 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate", "@php artisan key:generate",
"@php artisan migrate --force", "@php artisan migrate --force",
"npm install", "bun install",
"npm run build" "bun run build"
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
@ -67,9 +68,9 @@
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npm run lint:check", "bun run lint:check",
"npm run format:check", "bun run format:check",
"npm run types:check", "bun run types:check",
"@test" "@test"
], ],
"types:check": [ "types:check": [

416
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "379c97a9e3b22c91d1427f44e711a975", "content-hash": "96c16aa645077a07df0727ce7f38e195",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -813,6 +813,72 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "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", "name": "fruitcake/php-cors",
"version": "v1.4.0", "version": "v1.4.0",
@ -1966,6 +2032,78 @@
}, },
"time": "2026-07-21T16:49:22+00:00" "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", "name": "laravel/tinker",
"version": "v3.0.2", "version": "v3.0.2",
@ -2475,6 +2613,82 @@
], ],
"time": "2026-07-09T11:49:27+00:00" "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", "name": "league/uri",
"version": "7.8.1", "version": "7.8.1",
@ -3870,6 +4084,126 @@
], ],
"time": "2025-12-27T19:41:33+00:00" "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", "name": "phpstan/phpdoc-parser",
"version": "2.3.3", "version": "2.3.3",
@ -6854,6 +7188,86 @@
], ],
"time": "2026-04-10T16:19:22+00:00" "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", "name": "symfony/polyfill-php84",
"version": "v1.38.1", "version": "v1.38.1",

View File

@ -35,4 +35,10 @@
], ],
], ],
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI'),
],
]; ];

View File

@ -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

File diff suppressed because it is too large Load Diff

8492
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +0,0 @@
packages:
- '.'
publicHoistPattern:
- '@inertiajs/core'

View File

@ -45,6 +45,7 @@ export type FilterField =
placeholder?: string; placeholder?: string;
type?: 'select'; type?: 'select';
options: FilterOption[]; options: FilterOption[];
disabled?: boolean;
} }
| { | {
key: string; key: string;
@ -52,6 +53,7 @@ export type FilterField =
placeholder?: string; placeholder?: string;
type: 'combobox'; type: 'combobox';
groups: FilterOptionGroup[]; groups: FilterOptionGroup[];
disabled?: boolean;
}; };
type FilterDialogProps = { type FilterDialogProps = {
@ -85,10 +87,12 @@ function ComboboxFilterField({
isItemEqualToValue={(a: FilterOption, b: FilterOption) => isItemEqualToValue={(a: FilterOption, b: FilterOption) =>
a.value === b.value a.value === b.value
} }
disabled={field.disabled}
> >
<ComboboxInput <ComboboxInput
placeholder={field.placeholder ?? 'Semua'} placeholder={field.placeholder ?? 'Semua'}
showClear showClear
disabled={field.disabled}
className="w-full" className="w-full"
/> />
<ComboboxContent> <ComboboxContent>
@ -190,6 +194,7 @@ export function FilterDialog({
onValueChange={(value) => onValueChange={(value) =>
handleChange(field.key, value) handleChange(field.key, value)
} }
disabled={field.disabled}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue <SelectValue

View File

@ -1,3 +1,4 @@
import { useFlashToast } from '@/hooks/use-flash-toast';
import AuthLayoutTemplate from '@/layouts/auth/auth-split-layout'; import AuthLayoutTemplate from '@/layouts/auth/auth-split-layout';
export default function AuthLayout({ export default function AuthLayout({
@ -9,6 +10,8 @@ export default function AuthLayout({
description?: string; description?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
useFlashToast();
return ( return (
<AuthLayoutTemplate title={title} description={description}> <AuthLayoutTemplate title={title} description={description}>
{children} {children}

View File

@ -45,6 +45,7 @@ import {
submit, submit,
update, update,
} from '@/routes/admin/academic-classes/assignments'; } from '@/routes/admin/academic-classes/assignments';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { Assignment } from '@/types/assignment'; import type { Assignment } from '@/types/assignment';
import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment'; import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
import { createAssignmentCard } from './card'; import { createAssignmentCard } from './card';
@ -63,6 +64,12 @@ type CourseClassOption = {
type CourseClassGroup = { value: string; items: CourseClassOption[] }; type CourseClassGroup = { value: string; items: CourseClassOption[] };
type AcademicTermOption = {
id: number;
academic_year: string;
semester: string;
};
type Props = { type Props = {
assignments: { assignments: {
data: Assignment[]; data: Assignment[];
@ -72,9 +79,12 @@ type Props = {
total: number; total: number;
}; };
courseClasses: CourseClassOption[]; courseClasses: CourseClassOption[];
filterCourseClasses: CourseClassOption[];
academicTerms: AcademicTermOption[];
highlight?: number; highlight?: number;
filters: { filters: {
course_class_id?: string; course_class_id?: string;
academic_term_id?: string;
}; };
}; };
@ -161,6 +171,8 @@ function CourseClassField({
export default function AssignmentIndex({ export default function AssignmentIndex({
assignments, assignments,
courseClasses, courseClasses,
filterCourseClasses,
academicTerms,
highlight, highlight,
filters, filters,
}: Props) { }: Props) {
@ -177,11 +189,24 @@ export default function AssignmentIndex({
const canSubmit = hasPermission('submit-assignments'); const canSubmit = hasPermission('submit-assignments');
const filterFields = [ const filterFields = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: formatAcademicTermLabel(term),
})),
},
{ {
key: 'course_class_id', key: 'course_class_id',
label: 'Kelas', label: 'Kelas',
type: 'combobox' as const, 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'], 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() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -233,8 +294,8 @@ export default function AssignmentIndex({
<> <>
<FilterDialog <FilterDialog
fields={filterFields} fields={filterFields}
activeFilters={filters} activeFilters={filterDialogActiveFilters}
onApply={applyFilters} onApply={handleApplyFilters}
/> />
<ViewToggle value={view} onChange={setView} /> <ViewToggle value={view} onChange={setView} />
</> </>

View File

@ -36,6 +36,7 @@ import {
store, store,
update, update,
} from '@/routes/admin/academic-classes/materials'; } from '@/routes/admin/academic-classes/materials';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { Material } from '@/types/material'; import type { Material } from '@/types/material';
import { createMaterialCard } from './card'; import { createMaterialCard } from './card';
import { createMaterialColumns } from './columns'; import { createMaterialColumns } from './columns';
@ -53,6 +54,12 @@ type CourseClassOption = {
type CourseClassGroup = { value: string; items: CourseClassOption[] }; type CourseClassGroup = { value: string; items: CourseClassOption[] };
type AcademicTermOption = {
id: number;
academic_year: string;
semester: string;
};
type Props = { type Props = {
materials: { materials: {
data: Material[]; data: Material[];
@ -62,9 +69,12 @@ type Props = {
total: number; total: number;
}; };
courseClasses: CourseClassOption[]; courseClasses: CourseClassOption[];
filterCourseClasses: CourseClassOption[];
academicTerms: AcademicTermOption[];
highlight?: number; highlight?: number;
filters: { filters: {
course_class_id?: string; course_class_id?: string;
academic_term_id?: string;
}; };
}; };
@ -151,6 +161,8 @@ function courseClassFilterGroups(
export default function MaterialIndex({ export default function MaterialIndex({
materials, materials,
courseClasses, courseClasses,
filterCourseClasses,
academicTerms,
highlight, highlight,
filters, filters,
}: Props) { }: Props) {
@ -164,11 +176,24 @@ export default function MaterialIndex({
const canDelete = hasPermission('delete-materials'); const canDelete = hasPermission('delete-materials');
const filterFields = [ const filterFields = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: formatAcademicTermLabel(term),
})),
},
{ {
key: 'course_class_id', key: 'course_class_id',
label: 'Kelas', label: 'Kelas',
type: 'combobox' as const, 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'], 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() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -214,8 +272,8 @@ export default function MaterialIndex({
<> <>
<FilterDialog <FilterDialog
fields={filterFields} fields={filterFields}
activeFilters={filters} activeFilters={filterDialogActiveFilters}
onApply={applyFilters} onApply={handleApplyFilters}
/> />
<ViewToggle value={view} onChange={setView} /> <ViewToggle value={view} onChange={setView} />
</> </>

View File

@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import google from '@/routes/auth/google';
import { store } from '@/routes/login'; import { store } from '@/routes/login';
import { request } from '@/routes/password'; import { request } from '@/routes/password';
@ -105,29 +106,31 @@ export default function Login({ status, canResetPassword }: Props) {
</div> </div>
<Button <Button
type="button" asChild
variant="outline" variant="outline"
className="w-full" className="w-full"
> >
<svg className="h-4 w-4" viewBox="0 0 24 24"> <a href={google.redirect.url()}>
<path <svg className="h-4 w-4" viewBox="0 0 24 24">
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" <path
fill="#4285F4" 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" <path
fill="#34A853" 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" <path
fill="#FBBC05" 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" <path
fill="#EA4335" 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 </svg>
Masuk dengan Google
</a>
</Button> </Button>
</div> </div>
</> </>

View File

@ -1,11 +1,17 @@
<?php <?php
use App\Http\Controllers\Auth\GoogleAuthController;
use App\Http\Controllers\MediaUploadController; use App\Http\Controllers\MediaUploadController;
use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::inertia('/', 'welcome')->name('home'); 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::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'dashboard')->name('dashboard'); Route::inertia('dashboard', 'dashboard')->name('dashboard');