Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0372b421 | ||
|
|
1be2d06ff4 | ||
|
|
0f62445c3e |
@@ -43,3 +43,7 @@ storage/framework/sessions/*
|
|||||||
storage/framework/testing/*
|
storage/framework/testing/*
|
||||||
storage/framework/views/*
|
storage/framework/views/*
|
||||||
storage/logs/*
|
storage/logs/*
|
||||||
|
|
||||||
|
# Native build artifacts are independent of the Laravel image.
|
||||||
|
native/src-tauri/target
|
||||||
|
native/src-tauri/gen
|
||||||
|
|||||||
@@ -28,3 +28,15 @@ yarn-error.log
|
|||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
.codex/config.toml
|
.codex/config.toml
|
||||||
|
|
||||||
|
/native/node_modules
|
||||||
|
/native/src-tauri/target
|
||||||
|
/native/src-tauri/gen/*
|
||||||
|
!/native/src-tauri/gen/apple/
|
||||||
|
/native/src-tauri/gen/apple/build/
|
||||||
|
/native/src-tauri/gen/apple/Externals/
|
||||||
|
/native/src-tauri/gen/apple/assets/
|
||||||
|
/native/src-tauri/gen/apple/Pods/
|
||||||
|
/native/src-tauri/gen/apple/ExportOptions.plist
|
||||||
|
/native/src-tauri/gen/apple/**/*.xcuserstate
|
||||||
|
/native/src-tauri/gen/apple/**/xcuserdata/
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Models\Project;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -114,41 +113,6 @@ class PermissionService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param EloquentCollection<int, User> $users
|
|
||||||
* @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array<int, int>}
|
|
||||||
*/
|
|
||||||
public function projectAccessMap(EloquentCollection $users, Project $project): array
|
|
||||||
{
|
|
||||||
$users->loadMissing('teams');
|
|
||||||
$directPermissions = $this->assignmentMap(User::class, $project);
|
|
||||||
$teamPermissions = $this->assignmentMap(Team::class, $project);
|
|
||||||
$globalPermissions = $this->assignmentMap(User::class);
|
|
||||||
$effectivePermissions = [];
|
|
||||||
$globalAdminUserIds = [];
|
|
||||||
|
|
||||||
foreach ($users as $user) {
|
|
||||||
$permissions = $directPermissions[$user->id] ?? [];
|
|
||||||
|
|
||||||
foreach ($user->teams as $team) {
|
|
||||||
$permissions = array_merge($permissions, $teamPermissions[$team->id] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array(Permission::GlobalAdmin->value, $globalPermissions[$user->id] ?? [], true)) {
|
|
||||||
$permissions[] = Permission::GlobalAdmin->value;
|
|
||||||
$globalAdminUserIds[] = $user->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$expanded = $this->expandPermissions(array_map(Permission::from(...), $permissions));
|
|
||||||
$effectivePermissions[$user->id] = Permission::values(array_values(array_filter(
|
|
||||||
Permission::projectAssignable(),
|
|
||||||
fn (Permission $permission): bool => in_array($permission, $expanded, true),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return compact('directPermissions', 'teamPermissions', 'effectivePermissions', 'globalAdminUserIds');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<int, string>>
|
* @return array<string, array<int, string>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -182,22 +182,25 @@ class ProjectController extends Controller
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
|
|
||||||
$users = User::query()->orderBy('name')->get();
|
|
||||||
|
|
||||||
return Inertia::render('projects/Access', [
|
return Inertia::render('projects/Access', [
|
||||||
...$this->permissionService->projectAccessMap($users, $project),
|
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
||||||
'permissionOptions' => PermissionOptionResource::collection(
|
'permissionOptions' => PermissionOptionResource::collection(
|
||||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
|
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
||||||
'teams' => TeamResource::collection(
|
'teams' => TeamResource::collection(
|
||||||
Team::query()
|
Team::query()
|
||||||
->with('users')
|
->with('users')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(),
|
->get(),
|
||||||
)->resolve(),
|
)->resolve(),
|
||||||
'users' => UserResource::collection($users)->resolve(),
|
'users' => UserResource::collection(
|
||||||
|
User::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
)->resolve(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,9 +220,7 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($user, $project, $permissions);
|
$this->permissionService->sync($user, $project, $permissions);
|
||||||
|
|
||||||
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
|
return to_route('projects.access', $project)
|
||||||
? to_route('projects.access', $project)
|
|
||||||
: to_route('projects.index'))
|
|
||||||
->with('status', "{$user->name} permissions were updated.");
|
->with('status', "{$user->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,9 +239,7 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($team, $project, $permissions);
|
$this->permissionService->sync($team, $project, $permissions);
|
||||||
|
|
||||||
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
|
return to_route('projects.access', $project)
|
||||||
? to_route('projects.access', $project)
|
|
||||||
: to_route('projects.index'))
|
|
||||||
->with('status', "{$team->name} permissions were updated.");
|
->with('status', "{$team->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ const eslintConfig = [
|
|||||||
ignores: [
|
ignores: [
|
||||||
'bootstrap/ssr/**',
|
'bootstrap/ssr/**',
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
'node_modules/**',
|
'**/node_modules/**',
|
||||||
|
'native/src-tauri/target/**',
|
||||||
|
'native/src-tauri/gen/**',
|
||||||
'.github/**',
|
'.github/**',
|
||||||
'**/*.yml',
|
'**/*.yml',
|
||||||
'**/*.yaml',
|
'**/*.yaml',
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "kanbangit-native",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"build": "tauri build",
|
||||||
|
"build:macos": "tauri build --debug --bundles app",
|
||||||
|
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
|
||||||
|
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
|
||||||
|
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
||||||
|
"dev:ios": "tauri ios dev",
|
||||||
|
"build:ios:simulator": "rm -rf src-tauri/gen/apple/build/arm64-sim/KanbanGit.app && tauri ios build --debug --target aarch64-sim --no-sign --ci",
|
||||||
|
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only",
|
||||||
|
"open:ios": "tauri ios dev --open --no-dev-server",
|
||||||
|
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
||||||
|
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "2.11.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
devDependencies:
|
||||||
|
'@tauri-apps/cli':
|
||||||
|
specifier: 2.11.4
|
||||||
|
version: 2.11.4
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.11.4':
|
||||||
|
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.11.4':
|
||||||
|
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
|
||||||
|
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
|
||||||
|
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
|
||||||
|
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
|
||||||
|
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
|
||||||
|
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.11.4':
|
||||||
|
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
|
||||||
|
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
|
||||||
|
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
|
||||||
|
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.11.4':
|
||||||
|
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.11.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@tauri-apps/cli-darwin-arm64': 2.11.4
|
||||||
|
'@tauri-apps/cli-darwin-x64': 2.11.4
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
|
||||||
|
'@tauri-apps/cli-linux-x64-musl': 2.11.4
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
minimumReleaseAge: 10080
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "kanbangit-native"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "KanbanGit native application"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "kanbangit_native"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tauri = { version = "2", features = ["test"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
smoke-test = []
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "macos_smoke"
|
||||||
|
harness = false
|
||||||
|
required-features = ["smoke-test"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
xcuserdata/
|
||||||
|
build/
|
||||||
|
Externals/
|
||||||
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 955 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-20x20@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-29x29@2x-1.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-29x29@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-40x40@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-60x60@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "AppIcon-60x60@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-20x20@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-20x20@2x-1.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-40x40@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-40x40@2x-1.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-76x76@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-76x76@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "83.5x83.5",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "AppIcon-83.5x83.5@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "1024x1024",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"filename" : "AppIcon-512@2x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
|
||||||
|
<dependencies>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="s0d-6b-0kx">
|
||||||
|
<objects>
|
||||||
|
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<systemColor name="systemBackgroundColor">
|
||||||
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</systemColor>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
platform :ios, '17.0'
|
||||||
|
|
||||||
|
target 'kanbangit-native_iOS' do
|
||||||
|
# Pods for kanbangit-native_iOS
|
||||||
|
end
|
||||||
|
|
||||||
|
# Inherit the deployment target from the Podfile.
|
||||||
|
post_install do |installer|
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WorkspaceServer: Codable, Equatable {
|
||||||
|
let url: URL
|
||||||
|
let profile: UUID
|
||||||
|
|
||||||
|
static func normalize(_ input: String) -> URL? {
|
||||||
|
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard input.lowercased().hasPrefix("https://"),
|
||||||
|
!input.contains("\\"),
|
||||||
|
input.rangeOfCharacter(from: .controlCharacters) == nil,
|
||||||
|
var components = URLComponents(string: input),
|
||||||
|
components.scheme?.lowercased() == "https",
|
||||||
|
let host = components.host, !host.isEmpty,
|
||||||
|
components.user == nil, components.password == nil,
|
||||||
|
components.query == nil, components.fragment == nil,
|
||||||
|
components.port.map({ (1...65535).contains($0) }) ?? true else { return nil }
|
||||||
|
components.scheme = "https"
|
||||||
|
components.host = host.lowercased()
|
||||||
|
if components.port == 443 { components.port = nil }
|
||||||
|
if !components.path.hasSuffix("/") { components.path += "/" }
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
func allows(_ destination: URL) -> Bool {
|
||||||
|
destination.scheme?.lowercased() == "https"
|
||||||
|
&& destination.host?.lowercased() == url.host?.lowercased()
|
||||||
|
&& (destination.port ?? 443) == (url.port ?? 443)
|
||||||
|
&& destination.user == nil && destination.password == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer {
|
||||||
|
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspaceConnection {
|
||||||
|
private(set) var revision = 0
|
||||||
|
private(set) var ready = false
|
||||||
|
private(set) var connecting = false
|
||||||
|
|
||||||
|
mutating func begin() -> Int {
|
||||||
|
revision += 1
|
||||||
|
connecting = true
|
||||||
|
return revision
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func finish() {
|
||||||
|
connecting = false
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func fail(_ expectedRevision: Int) -> Bool {
|
||||||
|
guard expectedRevision == revision, connecting else { return false }
|
||||||
|
connecting = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
@_cdecl("install_ios_workspace")
|
||||||
|
func installIOSWorkspace() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let root = UIApplication.shared.connectedScenes
|
||||||
|
.compactMap({ $0 as? UIWindowScene }).flatMap(\.windows)
|
||||||
|
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||||
|
?? UIApplication.shared.windows.first?.rootViewController else { return }
|
||||||
|
guard !root.children.contains(where: { $0 is WorkspaceViewController }) else { return }
|
||||||
|
let controller = WorkspaceViewController()
|
||||||
|
root.view.subviews.forEach { $0.isHidden = true }
|
||||||
|
root.addChild(controller)
|
||||||
|
root.view.addSubview(controller.view)
|
||||||
|
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
controller.view.topAnchor.constraint(equalTo: root.view.topAnchor),
|
||||||
|
controller.view.bottomAnchor.constraint(equalTo: root.view.bottomAnchor),
|
||||||
|
controller.view.leadingAnchor.constraint(equalTo: root.view.leadingAnchor),
|
||||||
|
controller.view.trailingAnchor.constraint(equalTo: root.view.trailingAnchor),
|
||||||
|
])
|
||||||
|
controller.didMove(toParent: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let serverKey = "ios.activeServer"
|
||||||
|
private(set) var server: WorkspaceServer?
|
||||||
|
private(set) var workspace: WKWebView?
|
||||||
|
private var connection = WorkspaceConnection()
|
||||||
|
private var pendingNavigation: WKNavigation?
|
||||||
|
private var timeout: DispatchWorkItem?
|
||||||
|
private let launcher = UIScrollView()
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let address = UITextField()
|
||||||
|
private let status = UILabel()
|
||||||
|
private let connectButton = UIButton(type: .system)
|
||||||
|
private let resumeButton = UIButton(type: .system)
|
||||||
|
private let toolbar = UIToolbar()
|
||||||
|
private var backButton: UIBarButtonItem!
|
||||||
|
private var appearance: UISegmentedControl!
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
private func text(_ key: String) -> String {
|
||||||
|
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
||||||
|
configureLauncher()
|
||||||
|
configureToolbar()
|
||||||
|
if let data = defaults.data(forKey: serverKey) {
|
||||||
|
if let saved = try? JSONDecoder().decode(WorkspaceServer.self, from: data),
|
||||||
|
WorkspaceServer.normalize(saved.url.absoluteString) == saved.url {
|
||||||
|
server = saved
|
||||||
|
} else {
|
||||||
|
status.text = text("storageError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||||
|
showSettings()
|
||||||
|
if let server { open(server.url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text(key)
|
||||||
|
label.font = .preferredFont(forTextStyle: style)
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.numberOfLines = 0
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureLauncher() {
|
||||||
|
launcher.accessibilityIdentifier = "serverSettings"
|
||||||
|
launcher.keyboardDismissMode = .interactive
|
||||||
|
launcher.alwaysBounceVertical = true
|
||||||
|
view.addSubview(launcher)
|
||||||
|
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
launcher.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
launcher.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
launcher.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
launcher.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 18
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
launcher.addSubview(stack)
|
||||||
|
let width = stack.widthAnchor.constraint(equalTo: launcher.frameLayoutGuide.widthAnchor, constant: -40)
|
||||||
|
width.priority = .defaultHigh
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: launcher.contentLayoutGuide.topAnchor, constant: 32),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: launcher.contentLayoutGuide.bottomAnchor, constant: -32),
|
||||||
|
stack.centerXAnchor.constraint(equalTo: launcher.frameLayoutGuide.centerXAnchor),
|
||||||
|
stack.leadingAnchor.constraint(greaterThanOrEqualTo: launcher.contentLayoutGuide.leadingAnchor, constant: 20),
|
||||||
|
stack.trailingAnchor.constraint(lessThanOrEqualTo: launcher.contentLayoutGuide.trailingAnchor, constant: -20),
|
||||||
|
stack.widthAnchor.constraint(lessThanOrEqualToConstant: 480), width,
|
||||||
|
])
|
||||||
|
stack.addArrangedSubview(label("appName", style: .headline))
|
||||||
|
stack.addArrangedSubview(label("title", style: .largeTitle))
|
||||||
|
stack.addArrangedSubview(label("description"))
|
||||||
|
stack.addArrangedSubview(label("serverLabel", style: .headline))
|
||||||
|
address.borderStyle = .roundedRect
|
||||||
|
address.keyboardType = .URL
|
||||||
|
address.textContentType = .URL
|
||||||
|
address.autocorrectionType = .no
|
||||||
|
address.autocapitalizationType = .none
|
||||||
|
address.clearButtonMode = .whileEditing
|
||||||
|
address.returnKeyType = .go
|
||||||
|
address.delegate = self
|
||||||
|
address.accessibilityLabel = text("serverLabel")
|
||||||
|
address.accessibilityIdentifier = "serverAddress"
|
||||||
|
address.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true
|
||||||
|
stack.addArrangedSubview(address)
|
||||||
|
stack.addArrangedSubview(label("serverHelp", style: .footnote))
|
||||||
|
connectButton.configuration = .filled()
|
||||||
|
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||||
|
connectButton.accessibilityIdentifier = "openWorkspace"
|
||||||
|
connectButton.addTarget(self, action: #selector(connect), for: .touchUpInside)
|
||||||
|
stack.addArrangedSubview(connectButton)
|
||||||
|
status.numberOfLines = 0
|
||||||
|
status.accessibilityIdentifier = "connectionStatus"
|
||||||
|
status.font = .preferredFont(forTextStyle: .body)
|
||||||
|
status.adjustsFontForContentSizeCategory = true
|
||||||
|
stack.addArrangedSubview(status)
|
||||||
|
resumeButton.setTitle(text("resume"), for: .normal)
|
||||||
|
resumeButton.accessibilityIdentifier = "resumeWorkspace"
|
||||||
|
resumeButton.addTarget(self, action: #selector(showWorkspace), for: .touchUpInside)
|
||||||
|
stack.addArrangedSubview(resumeButton)
|
||||||
|
stack.addArrangedSubview(label("appearance", style: .headline))
|
||||||
|
appearance = UISegmentedControl(items: [text("system"), text("light"), text("dark")])
|
||||||
|
appearance.selectedSegmentIndex = min(2, max(0, defaults.integer(forKey: "ios.appearance")))
|
||||||
|
appearance.accessibilityIdentifier = "appearance"
|
||||||
|
appearance.addTarget(self, action: #selector(changeAppearance), for: .valueChanged)
|
||||||
|
stack.addArrangedSubview(appearance)
|
||||||
|
[connectButton, resumeButton, appearance].forEach {
|
||||||
|
$0.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
|
||||||
|
}
|
||||||
|
changeAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureToolbar() {
|
||||||
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(toolbar)
|
||||||
|
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
||||||
|
backButton.accessibilityIdentifier = "workspaceBack"
|
||||||
|
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
||||||
|
settings.accessibilityIdentifier = "showServerSettings"
|
||||||
|
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func changeAppearance() {
|
||||||
|
defaults.set(appearance.selectedSegmentIndex, forKey: "ios.appearance")
|
||||||
|
overrideUserInterfaceStyle = [.unspecified, .light, .dark][appearance.selectedSegmentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool { connect(); return true }
|
||||||
|
|
||||||
|
@objc private func connect() {
|
||||||
|
guard let url = WorkspaceServer.normalize(address.text ?? "") else {
|
||||||
|
status.text = text("invalidServer")
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: status.text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let server, server.url != url {
|
||||||
|
let alert = UIAlertController(title: text("confirmSwitch"), message: text("switchWarning"), preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: text("cancel"), style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: text("confirmSwitch"), style: .destructive) { [weak self] _ in self?.open(url) })
|
||||||
|
present(alert, animated: true)
|
||||||
|
} else { open(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ url: URL) {
|
||||||
|
if server?.url == url, connection.ready { showWorkspace(); return }
|
||||||
|
let selected = WorkspaceServer.selecting(url, current: server)
|
||||||
|
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||||
|
defaults.set(data, forKey: serverKey)
|
||||||
|
timeout?.cancel()
|
||||||
|
workspace?.stopLoading()
|
||||||
|
workspace?.navigationDelegate = nil
|
||||||
|
workspace?.removeFromSuperview()
|
||||||
|
connection = WorkspaceConnection()
|
||||||
|
server = selected
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
||||||
|
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
|
||||||
|
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||||
|
webview.navigationDelegate = self
|
||||||
|
webview.uiDelegate = self
|
||||||
|
webview.allowsBackForwardNavigationGestures = true
|
||||||
|
webview.accessibilityIdentifier = "workspace"
|
||||||
|
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
webview.isHidden = true
|
||||||
|
view.insertSubview(webview, belowSubview: launcher)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
webview.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
|
||||||
|
webview.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||||
|
webview.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
])
|
||||||
|
workspace = webview
|
||||||
|
view.endEditing(true)
|
||||||
|
address.text = url.absoluteString
|
||||||
|
beginNavigation()
|
||||||
|
pendingNavigation = webview.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginNavigation() {
|
||||||
|
timeout?.cancel()
|
||||||
|
let revision = connection.begin()
|
||||||
|
status.text = text("connecting")
|
||||||
|
connectButton.isEnabled = false
|
||||||
|
resumeButton.isHidden = !connection.ready
|
||||||
|
let work = DispatchWorkItem { [weak self] in self?.fail(revision) }
|
||||||
|
timeout = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: work)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(_ revision: Int) {
|
||||||
|
guard connection.fail(revision) else { return }
|
||||||
|
timeout?.cancel()
|
||||||
|
status.text = text("connectionError")
|
||||||
|
connectButton.isEnabled = true
|
||||||
|
connectButton.setTitle(text("retry"), for: .normal)
|
||||||
|
showSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showSettings() {
|
||||||
|
view.endEditing(true)
|
||||||
|
launcher.isHidden = false
|
||||||
|
toolbar.isHidden = true
|
||||||
|
workspace?.isHidden = true
|
||||||
|
resumeButton.isHidden = !connection.ready
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showWorkspace() {
|
||||||
|
guard connection.ready else { return }
|
||||||
|
view.endEditing(true)
|
||||||
|
launcher.isHidden = true
|
||||||
|
toolbar.isHidden = false
|
||||||
|
workspace?.isHidden = false
|
||||||
|
backButton.isEnabled = workspace?.canGoBack == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
guard webView === workspace, let url = action.request.url,
|
||||||
|
server?.allows(url) == true else { decisionHandler(.cancel); return }
|
||||||
|
if action.targetFrame == nil {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
webView.load(action.request)
|
||||||
|
} else { decisionHandler(.allow) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||||
|
guard webView === workspace, let url = response.response.url,
|
||||||
|
server?.allows(url) == true else { decisionHandler(.cancel); return }
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
|
guard webView === workspace else { return }
|
||||||
|
pendingNavigation = navigation
|
||||||
|
beginNavigation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
guard webView === workspace, navigation === pendingNavigation else { return }
|
||||||
|
let firstLoad = !connection.ready
|
||||||
|
timeout?.cancel()
|
||||||
|
connection.finish()
|
||||||
|
connectButton.isEnabled = true
|
||||||
|
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||||
|
status.text = text("connected")
|
||||||
|
resumeButton.isHidden = false
|
||||||
|
backButton.isEnabled = webView.canGoBack
|
||||||
|
if firstLoad { showWorkspace() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
navigationFailed(webView, navigation, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
navigationFailed(webView, navigation, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigationFailed(_ webView: WKWebView, _ navigation: WKNavigation?, _ error: Error) {
|
||||||
|
guard webView === workspace, navigation === pendingNavigation,
|
||||||
|
(error as NSError).code != NSURLErrorCancelled else { return }
|
||||||
|
fail(connection.revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
|
guard webView === workspace else { return }
|
||||||
|
connection = WorkspaceConnection()
|
||||||
|
status.text = text("processTerminated")
|
||||||
|
connectButton.isEnabled = true
|
||||||
|
showSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace ffi {
|
||||||
|
extern "C" {
|
||||||
|
void start_app();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"appName" = "KanbanGit";
|
||||||
|
"title" = "Choose your workspace.";
|
||||||
|
"description" = "Connect to your KanbanGit installation over HTTPS.";
|
||||||
|
"serverLabel" = "Server address";
|
||||||
|
"serverHelp" = "Use the installation address without login details, a query or a fragment.";
|
||||||
|
"openWorkspace" = "Open workspace";
|
||||||
|
"retry" = "Try again";
|
||||||
|
"resume" = "Return to workspace";
|
||||||
|
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
||||||
|
"confirmSwitch" = "Switch server";
|
||||||
|
"cancel" = "Cancel";
|
||||||
|
"connecting" = "Connecting to your server…";
|
||||||
|
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
||||||
|
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
||||||
|
"invalidServer" = "Enter a valid HTTPS installation address without login details, a query or a fragment.";
|
||||||
|
"storageError" = "The saved server setting could not be read. Enter your server address again.";
|
||||||
|
"processTerminated" = "iOS closed the workspace to free memory. Open it again to continue. Unsaved inputs may have been lost.";
|
||||||
|
"appearance" = "Appearance";
|
||||||
|
"system" = "System";
|
||||||
|
"light" = "Light";
|
||||||
|
"dark" = "Dark";
|
||||||
|
"back" = "Back";
|
||||||
|
"serverSettings" = "Server settings";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#include "bindings/bindings.h"
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
extern "C" void install_ios_workspace(void);
|
||||||
|
|
||||||
|
int main(int argc, char * argv[]) {
|
||||||
|
@autoreleasepool {
|
||||||
|
// Xcode mirrors os_log to stderr. Tauri 2.11 redirects stderr back to os_log,
|
||||||
|
// which can fill its pipe and block UIKit/XCTest. Keep Xcode's original streams.
|
||||||
|
const bool debuggerLogging = getenv("OS_ACTIVITY_DT_MODE") != nullptr
|
||||||
|
|| getenv("XCTestConfigurationFilePath") != nullptr;
|
||||||
|
__block int originalOut = debuggerLogging ? dup(STDOUT_FILENO) : -1;
|
||||||
|
__block int originalError = debuggerLogging ? dup(STDERR_FILENO) : -1;
|
||||||
|
[[NSNotificationCenter defaultCenter]
|
||||||
|
addObserverForName:UIApplicationDidBecomeActiveNotification
|
||||||
|
object:nil
|
||||||
|
queue:NSOperationQueue.mainQueue
|
||||||
|
usingBlock:^(NSNotification *notification) {
|
||||||
|
if (originalOut >= 0) {
|
||||||
|
dup2(originalOut, STDOUT_FILENO);
|
||||||
|
close(originalOut);
|
||||||
|
originalOut = -1;
|
||||||
|
}
|
||||||
|
if (originalError >= 0) {
|
||||||
|
dup2(originalError, STDERR_FILENO);
|
||||||
|
close(originalError);
|
||||||
|
originalError = -1;
|
||||||
|
}
|
||||||
|
install_ios_workspace();
|
||||||
|
}];
|
||||||
|
ffi::start_app();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import XCTest
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
final class WorkspaceTests: XCTestCase {
|
||||||
|
func testServerValidationAndOriginBoundary() throws {
|
||||||
|
let url = try XCTUnwrap(WorkspaceServer.normalize(" HTTPS://Example.com:443/kanban "))
|
||||||
|
XCTAssertEqual(url.absoluteString, "https://example.com/kanban/")
|
||||||
|
let server = WorkspaceServer(url: url, profile: UUID())
|
||||||
|
for value in ["http://example.com/", "https://user:secret@example.com/", "https://example.com/?token=1", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\evil", "file:///etc/passwd", "javascript:alert(1)", "https://example.com:99999/"] {
|
||||||
|
XCTAssertNil(WorkspaceServer.normalize(value), value)
|
||||||
|
}
|
||||||
|
for value in ["https://example.com/login", "https://example.com/two-factor-challenge", "https://example.com/projects/1?ticket=54#details"] {
|
||||||
|
XCTAssertTrue(server.allows(try XCTUnwrap(URL(string: value))), value)
|
||||||
|
}
|
||||||
|
for value in ["http://example.com/", "https://example.com:8443/", "https://evil.example.com/", "https://user@example.com/", "file:///tmp/index.html", "tauri://localhost/action/open"] {
|
||||||
|
XCTAssertFalse(server.allows(try XCTUnwrap(URL(string: value))), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerChangesNeverReusePreviousProfile() throws {
|
||||||
|
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
|
||||||
|
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
|
||||||
|
let first = WorkspaceServer.selecting(firstURL, current: nil)
|
||||||
|
XCTAssertEqual(WorkspaceServer.selecting(firstURL, current: first), first)
|
||||||
|
let second = WorkspaceServer.selecting(otherURL, current: first)
|
||||||
|
XCTAssertNotEqual(second.profile, first.profile)
|
||||||
|
XCTAssertNotEqual(WorkspaceServer.selecting(firstURL, current: second).profile, first.profile)
|
||||||
|
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: JSONEncoder().encode(first)), first)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimeoutDoesNotDiscardLoadedWorkspaceOrAffectLaterNavigation() {
|
||||||
|
var state = WorkspaceConnection()
|
||||||
|
let first = state.begin()
|
||||||
|
let second = state.begin()
|
||||||
|
XCTAssertFalse(state.fail(first))
|
||||||
|
state.finish()
|
||||||
|
XCTAssertFalse(state.fail(second))
|
||||||
|
let third = state.begin()
|
||||||
|
XCTAssertTrue(state.fail(third))
|
||||||
|
XCTAssertTrue(state.ready)
|
||||||
|
XCTAssertFalse(state.fail(third))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testWKWebViewPersistsCookiesAcrossViewsAndIsolatesServerChanges() async throws {
|
||||||
|
let profile = UUID()
|
||||||
|
let first = WKWebViewConfiguration()
|
||||||
|
first.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
|
||||||
|
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
||||||
|
.domain: "example.com", .path: "/", .name: "laravel_session", .value: "test-session",
|
||||||
|
.secure: "TRUE", .expires: Date().addingTimeInterval(3600),
|
||||||
|
]))
|
||||||
|
await first.websiteDataStore.httpCookieStore.setCookie(cookie)
|
||||||
|
let restored = WKWebViewConfiguration()
|
||||||
|
restored.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
|
||||||
|
let restoredCookies = await restored.websiteDataStore.httpCookieStore.allCookies()
|
||||||
|
XCTAssertTrue(restoredCookies.contains { $0.name == "laravel_session" && $0.value == "test-session" })
|
||||||
|
let other = WKWebsiteDataStore(forIdentifier: UUID())
|
||||||
|
let otherCookies = await other.httpCookieStore.allCookies()
|
||||||
|
XCTAssertTrue(otherCookies.isEmpty)
|
||||||
|
await restored.websiteDataStore.httpCookieStore.deleteCookie(cookie)
|
||||||
|
let loggedOut = await first.websiteDataStore.httpCookieStore.allCookies()
|
||||||
|
XCTAssertFalse(loggedOut.contains { $0.name == "laravel_session" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testControllerRestoresServerAndCreatesIsolatedUnprivilegedWebView() throws {
|
||||||
|
let name = "KAN54-tests-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
||||||
|
controller.open(url)
|
||||||
|
let server = try XCTUnwrap(controller.server)
|
||||||
|
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
||||||
|
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
|
||||||
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
|
restored.loadViewIfNeeded()
|
||||||
|
XCTAssertEqual(restored.server, server)
|
||||||
|
let other = try XCTUnwrap(WorkspaceServer.normalize("https://other.invalid/"))
|
||||||
|
restored.open(other)
|
||||||
|
XCTAssertNotEqual(restored.server?.profile, server.profile)
|
||||||
|
}
|
||||||
|
@MainActor
|
||||||
|
func testConnectionFailureKeepsLoadedFormAndAllowsResume() async throws {
|
||||||
|
let name = "KAN54-form-\(UUID())"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
||||||
|
controller.open(url)
|
||||||
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
|
webview.stopLoading()
|
||||||
|
let navigation = try XCTUnwrap(webview.loadHTMLString("<html><body><input id='draft' value='unsaved ticket'></body></html>", baseURL: url))
|
||||||
|
var loaded = false
|
||||||
|
for _ in 0..<50 {
|
||||||
|
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
||||||
|
value as? String == "unsaved ticket" { loaded = true; break }
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(loaded)
|
||||||
|
controller.webView(webview, didFinish: navigation)
|
||||||
|
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||||
|
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||||
|
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||||
|
XCTAssertTrue(webview.isHidden)
|
||||||
|
controller.open(url)
|
||||||
|
XCTAssertTrue(controller.workspace === webview)
|
||||||
|
XCTAssertFalse(webview.isHidden)
|
||||||
|
let value = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
||||||
|
XCTAssertEqual(value as? String, "unsaved ticket")
|
||||||
|
let bridge = try await webview.evaluateJavaScript("typeof window.__TAURI_INTERNALS__")
|
||||||
|
XCTAssertEqual(bridge as? String, "undefined")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class WorkspaceUITests: XCTestCase {
|
||||||
|
@MainActor
|
||||||
|
private func reveal(_ element: XCUIElement, in scrollView: XCUIElement) {
|
||||||
|
for _ in 0..<8 {
|
||||||
|
if scrollView.frame.contains(CGPoint(x: element.frame.midX, y: element.frame.midY)), element.isHittable { return }
|
||||||
|
let start = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||||
|
let direction: CGFloat = element.frame.midY > scrollView.frame.midY ? 0.2 : 0.8
|
||||||
|
let end = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: direction))
|
||||||
|
start.press(forDuration: 0.05, thenDragTo: end)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(element.isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
let settings = app.buttons["showServerSettings"]
|
||||||
|
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||||
|
let address = app.textFields["serverAddress"]
|
||||||
|
XCTAssertTrue(address.waitForExistence(timeout: 15))
|
||||||
|
address.tap()
|
||||||
|
let clear = address.buttons.firstMatch
|
||||||
|
if clear.exists { clear.tap() }
|
||||||
|
address.typeText("http://invalid.example")
|
||||||
|
app.buttons["openWorkspace"].tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("valid HTTPS"))
|
||||||
|
XCUIDevice.shared.orientation = .landscapeLeft
|
||||||
|
let launcher = app.scrollViews["serverSettings"]
|
||||||
|
reveal(address, in: launcher)
|
||||||
|
let open = app.buttons["openWorkspace"]
|
||||||
|
reveal(open, in: launcher)
|
||||||
|
let dark = app.segmentedControls["appearance"].buttons["Dark"]
|
||||||
|
reveal(dark, in: launcher)
|
||||||
|
dark.tap()
|
||||||
|
XCTAssertTrue(dark.isSelected)
|
||||||
|
app.segmentedControls["appearance"].buttons["System"].tap()
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||||
|
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
||||||
|
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
||||||
|
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
||||||
|
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */; };
|
||||||
|
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8243D0C3821DAC731FECF105 /* UIKit.framework */; };
|
||||||
|
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
|
||||||
|
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||||
|
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
||||||
|
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||||
|
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||||
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||||
|
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||||
|
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||||
|
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; };
|
||||||
|
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
||||||
|
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
||||||
|
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
|
||||||
|
remoteInfo = "kanbangit-native_iOS";
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
01E10FB645809173AF1D4C2D /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
|
||||||
|
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
||||||
|
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
||||||
|
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||||
|
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||||
|
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||||
|
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
||||||
|
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||||
|
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||||
|
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||||
|
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
||||||
|
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
||||||
|
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||||
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||||
|
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
|
||||||
|
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
|
||||||
|
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
D581BB90601D7A89F8F188E2 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
|
||||||
|
DE3B71153677F722CAC6DCF7 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
|
||||||
|
E8BA104C81071623E1FF367D /* server.rs */ = {isa = PBXFileReference; path = server.rs; sourceTree = "<group>"; };
|
||||||
|
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUITests.swift; sourceTree = "<group>"; };
|
||||||
|
ED4469441C3E0F37B1123572 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceViewController.swift; sourceTree = "<group>"; };
|
||||||
|
F46963153D20DB21D97843E9 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
|
||||||
|
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
32E70C778E91CB2E8CD86715 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
D8772A5E508092D6F831058A /* libapp.a in Frameworks */,
|
||||||
|
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */,
|
||||||
|
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */,
|
||||||
|
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */,
|
||||||
|
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */,
|
||||||
|
B939982700E800053A9C7663 /* Security.framework in Frameworks */,
|
||||||
|
5E764E27CA85A3746845F417 /* UIKit.framework in Frameworks */,
|
||||||
|
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
140A7DB7E2461E64B1FBFA70 /* Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */,
|
||||||
|
);
|
||||||
|
path = Tests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
157AF88201BDE4D99AA2E14C = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
31630170C358EA8657E34854 /* Assets.xcassets */,
|
||||||
|
F63BC1BDE65F5AEBE5B13519 /* LaunchScreen.storyboard */,
|
||||||
|
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */,
|
||||||
|
8606F3EE5A736385061692EF /* Sources */,
|
||||||
|
335B1262A9019C70C7F887A5 /* src */,
|
||||||
|
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||||
|
3C9F31BB065F210267ECA390 /* UITests */,
|
||||||
|
3028C3786B437115B6486F1C /* Frameworks */,
|
||||||
|
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2A11A26F0082A5075CD710C6 /* bindings */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
01E10FB645809173AF1D4C2D /* bindings.h */,
|
||||||
|
);
|
||||||
|
path = bindings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3028C3786B437115B6486F1C /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */,
|
||||||
|
91991DFDB0C064B06DF854ED /* libapp.a */,
|
||||||
|
CA65E67046326EA008175045 /* Metal.framework */,
|
||||||
|
D581BB90601D7A89F8F188E2 /* MetalKit.framework */,
|
||||||
|
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */,
|
||||||
|
DE3B71153677F722CAC6DCF7 /* Security.framework */,
|
||||||
|
8243D0C3821DAC731FECF105 /* UIKit.framework */,
|
||||||
|
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
335B1262A9019C70C7F887A5 /* src */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
05A753935B6CB0316DA56D96 /* desktop.rs */,
|
||||||
|
CAF08AD60D2F29A879CDAEBB /* ios.rs */,
|
||||||
|
2B96972F58CC158AB3DFF94A /* lib.rs */,
|
||||||
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||||
|
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||||
|
E8BA104C81071623E1FF367D /* server.rs */,
|
||||||
|
);
|
||||||
|
name = src;
|
||||||
|
path = ../../src;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3C9F31BB065F210267ECA390 /* UITests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */,
|
||||||
|
);
|
||||||
|
path = UITests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4058655E6F37D054E25D1F37 /* kanbangit-native_iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D13B8D854BEB7757AB95231B /* Info.plist */,
|
||||||
|
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */,
|
||||||
|
);
|
||||||
|
path = "kanbangit-native_iOS";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
|
||||||
|
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
||||||
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8606F3EE5A736385061692EF /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
89AA023B8C28CB860513C685 /* kanbangit-native */,
|
||||||
|
);
|
||||||
|
path = Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
89AA023B8C28CB860513C685 /* kanbangit-native */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4C76059D31C50B198779A5F7 /* en.lproj */,
|
||||||
|
F46963153D20DB21D97843E9 /* main.mm */,
|
||||||
|
554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
|
||||||
|
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
|
||||||
|
2A11A26F0082A5075CD710C6 /* bindings */,
|
||||||
|
C8C02BC0E757897278EB954A /* Localizable.strings */,
|
||||||
|
);
|
||||||
|
path = "kanbangit-native";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */;
|
||||||
|
buildPhases = (
|
||||||
|
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */,
|
||||||
|
F39C45598E4345705F3820FA /* Sources */,
|
||||||
|
4A4C7FC1B57785DDD6B301FB /* Resources */,
|
||||||
|
32E70C778E91CB2E8CD86715 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = "kanbangit-native_iOS";
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = "kanbangit-native_iOS";
|
||||||
|
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
0258B26407CD2D8C61079F84 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = WorkspaceUITests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = WorkspaceUITests;
|
||||||
|
productReference = 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
|
CBC4561F8018D37DD49A0598 /* WorkspaceTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = WorkspaceTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = WorkspaceTests;
|
||||||
|
productReference = A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
AA6AD2A91A9E86D319D86D59 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1430;
|
||||||
|
TargetAttributes = {
|
||||||
|
721CF221CEDDC28FF2C5EBD5 = {
|
||||||
|
DevelopmentTeam = "";
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
988CFF304CB81C8F3A4D6612 = {
|
||||||
|
TestTargetID = 721CF221CEDDC28FF2C5EBD5;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = 157AF88201BDE4D99AA2E14C;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 59D2DE9FD94E6D2D64CB0117 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */,
|
||||||
|
CBC4561F8018D37DD49A0598 /* WorkspaceTests */,
|
||||||
|
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
4A4C7FC1B57785DDD6B301FB /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
|
||||||
|
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||||
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
C74F06A7ECCC2C6C79C74A2D /* Build Rust Code */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Build Rust Code";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
|
||||||
|
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "export PATH=\"$(dirname \"$(rustup which cargo)\"):$PATH\"\nexport RUSTC=\"$(rustup which rustc)\"\ncd \"$SRCROOT/../../..\"\n./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
0258B26407CD2D8C61079F84 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
2A0467365B852D44D1A9FD5B /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */,
|
||||||
|
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */,
|
||||||
|
6514B0DB3A5CCCDF7385AC18 /* WorkspaceViewController.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
F39C45598E4345705F3820FA /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
70EBA05ACBA5402617A3371C /* WorkspacePolicy.swift in Sources */,
|
||||||
|
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */,
|
||||||
|
333E7761EA87AE29CE04F1AC /* main.mm in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
5658F3B2915DC3A46531EDCE /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||||
|
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
ED4469441C3E0F37B1123572 /* en */,
|
||||||
|
);
|
||||||
|
name = Localizable.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
0C46162024E99BEFF872CABF /* release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
|
ARCHS = (
|
||||||
|
arm64,
|
||||||
|
);
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"\".\"",
|
||||||
|
);
|
||||||
|
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
||||||
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
|
PRODUCT_NAME = "KanbanGit";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALID_ARCHS = arm64;
|
||||||
|
};
|
||||||
|
name = release;
|
||||||
|
};
|
||||||
|
390A27695F28BF3AA9A049C9 /* debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = debug;
|
||||||
|
};
|
||||||
|
5110A47C3B60906E18EE09D2 /* debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = "kanbangit-native_iOS";
|
||||||
|
};
|
||||||
|
name = debug;
|
||||||
|
};
|
||||||
|
C81211E40BBB158A79AEBE3D /* release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = release;
|
||||||
|
};
|
||||||
|
D41150B2CAE4512D04C26759 /* debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
|
ARCHS = (
|
||||||
|
arm64,
|
||||||
|
);
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
|
||||||
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"\".\"",
|
||||||
|
);
|
||||||
|
INFOPLIST_FILE = "kanbangit-native_iOS/Info.plist";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
|
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.desktop;
|
||||||
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
|
PRODUCT_NAME = "KanbanGit";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALID_ARCHS = arm64;
|
||||||
|
};
|
||||||
|
name = debug;
|
||||||
|
};
|
||||||
|
EF104E75BD04AFEF735BAA52 /* release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.uitests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = "kanbangit-native_iOS";
|
||||||
|
};
|
||||||
|
name = release;
|
||||||
|
};
|
||||||
|
FB6E824120F9ED3CFE30E713 /* debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = debug;
|
||||||
|
};
|
||||||
|
FF1553DD6765EEA42FF50CDD /* release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
FB6E824120F9ED3CFE30E713 /* debug */,
|
||||||
|
FF1553DD6765EEA42FF50CDD /* release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = debug;
|
||||||
|
};
|
||||||
|
A9CA551DD3F3EBCCF8C41CC1 /* Build configuration list for PBXProject "kanbangit-native" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
390A27695F28BF3AA9A049C9 /* debug */,
|
||||||
|
C81211E40BBB158A79AEBE3D /* release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = debug;
|
||||||
|
};
|
||||||
|
E6F418AEF58E5F3D739C68F1 /* Build configuration list for PBXNativeTarget "WorkspaceUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
5110A47C3B60906E18EE09D2 /* debug */,
|
||||||
|
EF104E75BD04AFEF735BAA52 /* release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = debug;
|
||||||
|
};
|
||||||
|
F0C7EA9610FB69A5EA27B950 /* Build configuration list for PBXNativeTarget "kanbangit-native_iOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
D41150B2CAE4512D04C26759 /* debug */,
|
||||||
|
0C46162024E99BEFF872CABF /* release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>BuildSystemType</key>
|
||||||
|
<string>Original</string>
|
||||||
|
<key>DisableBuildSystemDeprecationDiagnostic</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1430"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||||
|
BuildableName = "KanbanGit.app"
|
||||||
|
BlueprintName = "kanbangit-native_iOS"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "NO">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||||
|
BuildableName = "KanbanGit.app"
|
||||||
|
BlueprintName = "kanbangit-native_iOS"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_BACKTRACE"
|
||||||
|
value = "full"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_LOG"
|
||||||
|
value = "info"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "CBC4561F8018D37DD49A0598"
|
||||||
|
BuildableName = "WorkspaceTests.xctest"
|
||||||
|
BlueprintName = "WorkspaceTests"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "988CFF304CB81C8F3A4D6612"
|
||||||
|
BuildableName = "WorkspaceUITests.xctest"
|
||||||
|
BlueprintName = "WorkspaceUITests"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||||
|
BuildableName = "KanbanGit.app"
|
||||||
|
BlueprintName = "kanbangit-native_iOS"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_BACKTRACE"
|
||||||
|
value = "full"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_LOG"
|
||||||
|
value = "info"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "NO"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "721CF221CEDDC28FF2C5EBD5"
|
||||||
|
BuildableName = "KanbanGit.app"
|
||||||
|
BlueprintName = "kanbangit-native_iOS"
|
||||||
|
ReferencedContainer = "container:kanbangit-native.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_BACKTRACE"
|
||||||
|
value = "full"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "RUST_LOG"
|
||||||
|
value = "info"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
<string>metal</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
name: kanbangit-native
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: de.kanbangit.desktop
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: 17.0
|
||||||
|
fileGroups: [../../src]
|
||||||
|
configs:
|
||||||
|
debug: debug
|
||||||
|
release: release
|
||||||
|
settingGroups:
|
||||||
|
app:
|
||||||
|
base:
|
||||||
|
PRODUCT_NAME: KanbanGit
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
PRODUCT_MODULE_NAME: KanbanGitNative
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
DEVELOPMENT_TEAM: ""
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.desktop
|
||||||
|
targetTemplates:
|
||||||
|
app:
|
||||||
|
type: application
|
||||||
|
sources:
|
||||||
|
- path: Sources
|
||||||
|
scheme:
|
||||||
|
environmentVariables:
|
||||||
|
RUST_BACKTRACE: full
|
||||||
|
RUST_LOG: info
|
||||||
|
settings:
|
||||||
|
groups: [app]
|
||||||
|
targets:
|
||||||
|
kanbangit-native_iOS:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: Sources
|
||||||
|
- path: Assets.xcassets
|
||||||
|
- path: kanbangit-native_iOS
|
||||||
|
- path: LaunchScreen.storyboard
|
||||||
|
info:
|
||||||
|
path: kanbangit-native_iOS/Info.plist
|
||||||
|
properties:
|
||||||
|
LSRequiresIPhoneOS: true
|
||||||
|
UILaunchStoryboardName: LaunchScreen
|
||||||
|
UIRequiredDeviceCapabilities: [arm64, metal]
|
||||||
|
UISupportedInterfaceOrientations:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
UISupportedInterfaceOrientations~ipad:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
CFBundleShortVersionString: 0.1.0
|
||||||
|
CFBundleVersion: "0.1.0"
|
||||||
|
entitlements:
|
||||||
|
path: kanbangit-native_iOS/kanbangit-native_iOS.entitlements
|
||||||
|
scheme:
|
||||||
|
environmentVariables:
|
||||||
|
RUST_BACKTRACE: full
|
||||||
|
RUST_LOG: info
|
||||||
|
testTargets:
|
||||||
|
- WorkspaceTests
|
||||||
|
- WorkspaceUITests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
ENABLE_BITCODE: false
|
||||||
|
ARCHS: [arm64]
|
||||||
|
VALID_ARCHS: arm64
|
||||||
|
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
|
||||||
|
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
|
||||||
|
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
|
||||||
|
groups: [app]
|
||||||
|
dependencies:
|
||||||
|
- framework: libapp.a
|
||||||
|
embed: false
|
||||||
|
- sdk: CoreGraphics.framework
|
||||||
|
- sdk: Metal.framework
|
||||||
|
- sdk: MetalKit.framework
|
||||||
|
- sdk: QuartzCore.framework
|
||||||
|
- sdk: Security.framework
|
||||||
|
- sdk: UIKit.framework
|
||||||
|
- sdk: WebKit.framework
|
||||||
|
preBuildScripts:
|
||||||
|
- script: |
|
||||||
|
export PATH="$(dirname "$(rustup which cargo)"):$PATH"
|
||||||
|
export RUSTC="$(rustup which rustc)"
|
||||||
|
cd "$SRCROOT/../../.."
|
||||||
|
./node_modules/.bin/tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
|
||||||
|
name: Build Rust Code
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
|
outputFiles:
|
||||||
|
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
|
||||||
|
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
|
||||||
|
WorkspaceTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: Tests
|
||||||
|
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||||
|
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||||
|
- path: Sources/kanbangit-native/en.lproj
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.tests
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
WorkspaceUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
platform: iOS
|
||||||
|
sources: [UITests]
|
||||||
|
dependencies:
|
||||||
|
- target: kanbangit-native_iOS
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.uitests
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
TEST_TARGET_NAME: kanbangit-native_iOS
|
||||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,456 @@
|
|||||||
|
use crate::locale::en;
|
||||||
|
use crate::server::{validate_url, Server, DEFAULT_SERVER};
|
||||||
|
use std::{path::PathBuf, sync::Mutex, time::Duration};
|
||||||
|
use tauri::{
|
||||||
|
menu::{MenuBuilder, SubmenuBuilder},
|
||||||
|
webview::{NewWindowResponse, PageLoadEvent},
|
||||||
|
AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Connection {
|
||||||
|
server: Option<Server>,
|
||||||
|
label: Option<String>,
|
||||||
|
revision: u64,
|
||||||
|
ready: bool,
|
||||||
|
phase: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
fn begin_navigation(&mut self) -> u64 {
|
||||||
|
self.revision += 1;
|
||||||
|
self.phase = "connecting";
|
||||||
|
self.revision
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timeout(&mut self, revision: u64) -> bool {
|
||||||
|
if self.revision != revision || self.phase != "connecting" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.phase = "connectionError";
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
|
||||||
|
destination.scheme() == "https"
|
||||||
|
&& destination.origin() == server.origin()
|
||||||
|
&& destination.username().is_empty()
|
||||||
|
&& destination.password().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allows_launcher_navigation(destination: &Url) -> bool {
|
||||||
|
destination.scheme() == "tauri"
|
||||||
|
&& destination.host_str() == Some("localhost")
|
||||||
|
&& destination.port().is_none()
|
||||||
|
&& destination.username().is_empty()
|
||||||
|
&& destination.password().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configuration_path(app: &AppHandle) -> tauri::Result<PathBuf> {
|
||||||
|
Ok(app.path().app_config_dir()?.join("active-server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_launcher(app: &AppHandle) {
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
let state = state.lock().unwrap();
|
||||||
|
let mut message = Url::parse("tauri://localhost/").unwrap();
|
||||||
|
message
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair(
|
||||||
|
"server",
|
||||||
|
state
|
||||||
|
.server
|
||||||
|
.as_ref()
|
||||||
|
.map_or(DEFAULT_SERVER, |server| server.url.as_str()),
|
||||||
|
)
|
||||||
|
.append_pair("phase", state.phase)
|
||||||
|
.append_pair("ready", if state.ready { "true" } else { "false" });
|
||||||
|
if let Some(window) = app.get_webview_window("launcher") {
|
||||||
|
let _ = window.eval(format!(
|
||||||
|
"window.updateLauncher?.('{}')",
|
||||||
|
message.query().unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_launcher(app: &AppHandle) {
|
||||||
|
update_launcher(app);
|
||||||
|
if let Some(window) = app.get_webview_window("launcher") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_navigation(app: &AppHandle, label: &str) {
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
let revision = {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
if state.label.as_deref() != Some(label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.begin_navigation()
|
||||||
|
};
|
||||||
|
update_launcher(app);
|
||||||
|
let handle = app.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(Duration::from_secs(15));
|
||||||
|
let state = handle.state::<Mutex<Connection>>();
|
||||||
|
let timed_out = state.lock().unwrap().timeout(revision);
|
||||||
|
if timed_out {
|
||||||
|
show_launcher(&handle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||||
|
let url = validate_url(input)?;
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
let (server, old_label, label) = {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
let same_server = state
|
||||||
|
.server
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|server| server.url == url);
|
||||||
|
if same_server && state.ready {
|
||||||
|
if let Some(window) = state
|
||||||
|
.label
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|label| app.get_webview_window(label))
|
||||||
|
{
|
||||||
|
window.show().map_err(|_| "connectionError")?;
|
||||||
|
window.set_focus().map_err(|_| "connectionError")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let server = if same_server {
|
||||||
|
state.server.clone().unwrap()
|
||||||
|
} else {
|
||||||
|
Server::new(url).map_err(|_| "storageError")?
|
||||||
|
};
|
||||||
|
server
|
||||||
|
.save(&configuration_path(app).map_err(|_| "storageError")?)
|
||||||
|
.map_err(|_| "storageError")?;
|
||||||
|
let old_label = state.label.take();
|
||||||
|
state.server = Some(server.clone());
|
||||||
|
state.ready = false;
|
||||||
|
state.revision += 1;
|
||||||
|
let label = format!("workspace-{}", state.revision);
|
||||||
|
state.label = Some(label.clone());
|
||||||
|
(server, old_label, label)
|
||||||
|
};
|
||||||
|
if let Some(window) = old_label.and_then(|label| app.get_webview_window(&label)) {
|
||||||
|
window.close().map_err(|_| "connectionError")?;
|
||||||
|
}
|
||||||
|
begin_navigation(app, &label);
|
||||||
|
let handle = app.clone();
|
||||||
|
let navigation_label = label.clone();
|
||||||
|
let server_url = server.url.clone();
|
||||||
|
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
|
||||||
|
.title(en::APP_NAME)
|
||||||
|
.inner_size(1280.0, 850.0)
|
||||||
|
.min_inner_size(375.0, 600.0)
|
||||||
|
.center()
|
||||||
|
.visible(false)
|
||||||
|
.data_store_identifier(server.profile)
|
||||||
|
.on_navigation(move |destination| {
|
||||||
|
if !allows_server_navigation(&server_url, destination) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
begin_navigation(&handle, &navigation_label);
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.on_new_window(|_, _| NewWindowResponse::Deny)
|
||||||
|
.on_page_load(|window, payload| {
|
||||||
|
if payload.event() != PageLoadEvent::Finished {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app = window.app_handle();
|
||||||
|
let state = app.state::<Mutex<Connection>>();
|
||||||
|
let should_show = {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
if state.label.as_deref() != Some(window.label()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let first_load = !state.ready;
|
||||||
|
state.ready = true;
|
||||||
|
state.phase = "connected";
|
||||||
|
first_load
|
||||||
|
};
|
||||||
|
update_launcher(app);
|
||||||
|
if should_show {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
if let Some(launcher) = app.get_webview_window("launcher") {
|
||||||
|
let _ = launcher.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.map_err(|_| "connectionError")?;
|
||||||
|
let handle = app.clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if matches!(event, WindowEvent::Destroyed) {
|
||||||
|
let state = handle.state::<Mutex<Connection>>();
|
||||||
|
let current = {
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
if state.label.as_deref() == Some(&label) {
|
||||||
|
state.label = None;
|
||||||
|
state.ready = false;
|
||||||
|
state.revision += 1;
|
||||||
|
state.phase = "idle";
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if current {
|
||||||
|
show_launcher(&handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||||
|
let result = match destination.path() {
|
||||||
|
"/action/open" => {
|
||||||
|
let input = destination
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(key, _)| key == "server")
|
||||||
|
.map(|(_, value)| value.into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
open_workspace(app, &input)
|
||||||
|
}
|
||||||
|
"/action/resume" => {
|
||||||
|
let label = app
|
||||||
|
.state::<Mutex<Connection>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.label
|
||||||
|
.clone();
|
||||||
|
if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||||
|
show_launcher(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||||
|
tauri::generate_context!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||||
|
tauri::Builder::default().setup(|app| {
|
||||||
|
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
||||||
|
Ok(server) => (server, "idle"),
|
||||||
|
Err(_) => (None, "storageError"),
|
||||||
|
};
|
||||||
|
app.manage(Mutex::new(Connection {
|
||||||
|
server,
|
||||||
|
phase,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
let menu = MenuBuilder::new(app)
|
||||||
|
.item(
|
||||||
|
&SubmenuBuilder::new(app, en::APP_NAME)
|
||||||
|
.text("server-settings", en::SERVER_SETTINGS)
|
||||||
|
.separator()
|
||||||
|
.quit()
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.item(
|
||||||
|
&SubmenuBuilder::new(app, en::EDIT)
|
||||||
|
.undo()
|
||||||
|
.redo()
|
||||||
|
.separator()
|
||||||
|
.cut()
|
||||||
|
.copy()
|
||||||
|
.paste()
|
||||||
|
.select_all()
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
app.set_menu(menu)?;
|
||||||
|
app.on_menu_event(|app, event| {
|
||||||
|
if event.id().as_ref() == "server-settings" {
|
||||||
|
show_launcher(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
let launcher =
|
||||||
|
WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into()))
|
||||||
|
.title(en::APP_NAME)
|
||||||
|
.inner_size(560.0, 720.0)
|
||||||
|
.min_inner_size(375.0, 640.0)
|
||||||
|
.center()
|
||||||
|
.on_navigation(move |destination| {
|
||||||
|
if !allows_launcher_navigation(destination) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if destination.path().starts_with("/action/") {
|
||||||
|
let handle = handle.clone();
|
||||||
|
let destination = destination.clone();
|
||||||
|
let _ = handle.clone().run_on_main_thread(move || {
|
||||||
|
handle_launcher_action(&handle, &destination)
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
matches!(destination.path(), "" | "/" | "/index.html")
|
||||||
|
})
|
||||||
|
.on_new_window(|_, _| NewWindowResponse::Deny)
|
||||||
|
.on_page_load(|window, payload| {
|
||||||
|
if payload.event() == PageLoadEvent::Finished {
|
||||||
|
let app = window.app_handle().clone();
|
||||||
|
update_launcher(&app);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let handle = app.clone();
|
||||||
|
let _ = app.run_on_main_thread(move || {
|
||||||
|
let server = handle
|
||||||
|
.state::<Mutex<Connection>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.server
|
||||||
|
.clone();
|
||||||
|
if let Some(server) = server {
|
||||||
|
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
||||||
|
{
|
||||||
|
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
|
||||||
|
error;
|
||||||
|
show_launcher(&handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()?;
|
||||||
|
let window = launcher.clone();
|
||||||
|
launcher.on_window_event(move |event| {
|
||||||
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_is_limited_to_the_selected_https_origin() {
|
||||||
|
let server = validate_url("https://example.com:8443/app/").unwrap();
|
||||||
|
for path in [
|
||||||
|
"/login",
|
||||||
|
"/two-factor-challenge",
|
||||||
|
"/projects/1?ticket=52#details",
|
||||||
|
] {
|
||||||
|
assert!(allows_server_navigation(
|
||||||
|
&server,
|
||||||
|
&server.join(path).unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for destination in [
|
||||||
|
"http://example.com:8443/",
|
||||||
|
"https://example.com/",
|
||||||
|
"https://other.example.com:8443/",
|
||||||
|
"https://user:secret@example.com:8443/",
|
||||||
|
"tauri://localhost/action/open?server=https://evil.test",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"data:text/html,hello",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
] {
|
||||||
|
assert!(!allows_server_navigation(
|
||||||
|
&server,
|
||||||
|
&Url::parse(destination).unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launcher_actions_are_restricted_to_the_bundled_origin() {
|
||||||
|
assert!(allows_launcher_navigation(
|
||||||
|
&Url::parse("tauri://localhost/action/open").unwrap()
|
||||||
|
));
|
||||||
|
for input in [
|
||||||
|
DEFAULT_SERVER,
|
||||||
|
"https://localhost/",
|
||||||
|
"tauri://other/index.html",
|
||||||
|
"tauri://user@localhost/index.html",
|
||||||
|
] {
|
||||||
|
assert!(!allows_launcher_navigation(&Url::parse(input).unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_timeouts_do_not_replace_newer_connections_or_loaded_forms() {
|
||||||
|
let mut state = Connection::default();
|
||||||
|
let first = state.begin_navigation();
|
||||||
|
let second = state.begin_navigation();
|
||||||
|
assert!(!state.timeout(first));
|
||||||
|
state.ready = true;
|
||||||
|
assert!(state.timeout(second));
|
||||||
|
assert!(state.ready);
|
||||||
|
assert!(!state.timeout(second));
|
||||||
|
let third = state.begin_navigation();
|
||||||
|
state.phase = "connected";
|
||||||
|
assert!(!state.timeout(third));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_pages_cannot_invoke_native_commands() {
|
||||||
|
let app = tauri::test::mock_builder().build(context()).unwrap();
|
||||||
|
let window = WebviewWindowBuilder::new(
|
||||||
|
&app,
|
||||||
|
"workspace-1",
|
||||||
|
WebviewUrl::External(Url::parse(DEFAULT_SERVER).unwrap()),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
for command in [
|
||||||
|
"plugin:window|close",
|
||||||
|
"plugin:webview|create_webview_window",
|
||||||
|
"plugin:path|resolve_directory",
|
||||||
|
] {
|
||||||
|
let response = tauri::test::get_ipc_response(
|
||||||
|
&window,
|
||||||
|
tauri::webview::InvokeRequest {
|
||||||
|
cmd: command.into(),
|
||||||
|
callback: tauri::ipc::CallbackFn(0),
|
||||||
|
error: tauri::ipc::CallbackFn(1),
|
||||||
|
url: Url::parse(DEFAULT_SERVER).unwrap(),
|
||||||
|
body: tauri::ipc::InvokeBody::default(),
|
||||||
|
headers: Default::default(),
|
||||||
|
invoke_key: tauri::test::INVOKE_KEY.into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(response
|
||||||
|
.unwrap_err()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("not allowed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packaged_app_exposes_no_native_capabilities_or_global_api() {
|
||||||
|
let config: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||||
|
assert_eq!(config["bundle"]["macOS"]["minimumSystemVersion"], "14.0");
|
||||||
|
assert_eq!(config["app"]["withGlobalTauri"], false);
|
||||||
|
assert_eq!(
|
||||||
|
config["app"]["security"]["capabilities"],
|
||||||
|
serde_json::json!([])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
|
pub fn context<R: tauri::Runtime>() -> tauri::Context<R> {
|
||||||
|
tauri::generate_context!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||||
|
tauri::Builder::default().setup(|app| {
|
||||||
|
WebviewWindowBuilder::new(app, "host", WebviewUrl::App("index.html".into()))
|
||||||
|
.on_navigation(|_| false)
|
||||||
|
.build()?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
|
mod desktop;
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
mod ios;
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
|
mod locale;
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "ios"))]
|
||||||
|
pub use desktop::{builder, context};
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
pub use ios::{builder, context};
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
builder().run(context()).expect("Could not start KanbanGit");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod en {
|
||||||
|
pub const APP_NAME: &str = "KanbanGit";
|
||||||
|
pub const SERVER_SETTINGS: &str = "Server settings…";
|
||||||
|
pub const EDIT: &str = "Edit";
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
kanbangit_native::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
use std::{fs, io, io::Read, path::Path};
|
||||||
|
use tauri::Url;
|
||||||
|
|
||||||
|
pub const DEFAULT_SERVER: &str = "https://kanbangit.de/";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Server {
|
||||||
|
pub url: Url,
|
||||||
|
pub profile: [u8; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_url(input: &str) -> Result<Url, &'static str> {
|
||||||
|
let input = input.trim();
|
||||||
|
if !input
|
||||||
|
.get(..8)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|
||||||
|
|| input
|
||||||
|
.chars()
|
||||||
|
.any(|character| character.is_control() || character == '\\')
|
||||||
|
{
|
||||||
|
return Err("invalidServer");
|
||||||
|
}
|
||||||
|
let mut url = Url::parse(input).map_err(|_| "invalidServer")?;
|
||||||
|
if url.scheme() != "https"
|
||||||
|
|| url.host_str().is_none()
|
||||||
|
|| !url.username().is_empty()
|
||||||
|
|| url.password().is_some()
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err("invalidServer");
|
||||||
|
}
|
||||||
|
if !url.path().ends_with('/') {
|
||||||
|
url.set_path(&format!("{}/", url.path()));
|
||||||
|
}
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn new(url: Url) -> io::Result<Self> {
|
||||||
|
let mut profile = [0; 16];
|
||||||
|
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
|
||||||
|
Ok(Self { url, profile })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> io::Result<Option<Self>> {
|
||||||
|
let contents = match fs::read_to_string(path) {
|
||||||
|
Ok(contents) => contents,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let invalid = || io::Error::new(io::ErrorKind::InvalidData, "Invalid server configuration");
|
||||||
|
let (url, profile) = contents.split_once('\n').ok_or_else(invalid)?;
|
||||||
|
let url = validate_url(url).map_err(|_| invalid())?;
|
||||||
|
if profile.len() != 32 || !profile.is_ascii() {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let mut bytes = [0; 16];
|
||||||
|
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||||
|
*byte = u8::from_str_radix(&profile[index * 2..index * 2 + 2], 16)
|
||||||
|
.map_err(|_| invalid())?;
|
||||||
|
}
|
||||||
|
Ok(Some(Self {
|
||||||
|
url,
|
||||||
|
profile: bytes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let profile: String = self
|
||||||
|
.profile
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect();
|
||||||
|
let temporary = path.with_extension("tmp");
|
||||||
|
fs::write(&temporary, format!("{}\n{profile}", self.url))?;
|
||||||
|
fs::rename(temporary, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_and_normalizes_installation_addresses() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_url(" HTTPS://Example.com:443/kanban ")
|
||||||
|
.unwrap()
|
||||||
|
.as_str(),
|
||||||
|
"https://example.com/kanban/"
|
||||||
|
);
|
||||||
|
assert!(validate_url("https://[::1]:8443/").is_ok());
|
||||||
|
for input in [
|
||||||
|
"http://example.com",
|
||||||
|
"https:example.com",
|
||||||
|
"https://user:secret@example.com",
|
||||||
|
"https://user@example.com",
|
||||||
|
"https://example.com/?token=secret",
|
||||||
|
"https://example.com/#secret",
|
||||||
|
"https://exa\nmple.com",
|
||||||
|
"https://example.com\\other",
|
||||||
|
"file:///tmp/test",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(validate_url(input).is_err(), "{input}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configuration_round_trip_preserves_only_the_address_and_profile() {
|
||||||
|
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("kanbangit-server-{:x?}/active", server.profile));
|
||||||
|
assert_eq!(Server::load(&path).unwrap(), None);
|
||||||
|
server.save(&path).unwrap();
|
||||||
|
assert_eq!(Server::load(&path).unwrap(), Some(server.clone()));
|
||||||
|
let other = Server::new(validate_url("https://other.example.com:444/").unwrap()).unwrap();
|
||||||
|
assert_ne!(server.profile, other.profile);
|
||||||
|
other.save(&path).unwrap();
|
||||||
|
assert_eq!(Server::load(&path).unwrap(), Some(other));
|
||||||
|
for contents in [
|
||||||
|
"http://example.com/\n00000000000000000000000000000000",
|
||||||
|
"invalid",
|
||||||
|
"https://example.com/\nshort",
|
||||||
|
"https://example.com/\nzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
||||||
|
] {
|
||||||
|
fs::write(&path, contents).unwrap();
|
||||||
|
assert!(Server::load(&path).is_err());
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "KanbanGit",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "de.kanbangit.desktop",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../ui"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [],
|
||||||
|
"withGlobalTauri": false,
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'",
|
||||||
|
"capabilities": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["app"],
|
||||||
|
"icon": ["icons/icon.png", "icons/icon.icns"],
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "14.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"bundle": {
|
||||||
|
"iOS": { "minimumSystemVersion": "17.0" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn main() {
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
use tauri::{webview::PageLoadEvent, Manager};
|
||||||
|
|
||||||
|
let passed = Arc::new(AtomicBool::new(false));
|
||||||
|
let stage = Arc::new(AtomicUsize::new(0));
|
||||||
|
let page_stage = stage.clone();
|
||||||
|
let result_passed = passed.clone();
|
||||||
|
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
||||||
|
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||||
|
.on_page_load(move |webview, payload| {
|
||||||
|
|
||||||
|
if payload.event() != PageLoadEvent::Finished {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if webview.label() == "launcher" {
|
||||||
|
webview.eval(r#"
|
||||||
|
(async () => {
|
||||||
|
const wait = async predicate => {
|
||||||
|
for (let attempt = 0; attempt < 220; attempt++) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error('Timed out waiting for local state');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace');
|
||||||
|
const input = document.querySelector('#server-url');
|
||||||
|
const form = document.querySelector('#server-form');
|
||||||
|
const status = document.querySelector('#connection-status');
|
||||||
|
input.value = 'http://example.com';
|
||||||
|
form.requestSubmit();
|
||||||
|
if (input.getAttribute('aria-invalid') !== 'true' || !status.textContent.includes('HTTPS')) throw new Error('Invalid address accepted');
|
||||||
|
for (const [theme, expected] of [['dark', 'rgb(15, 23, 42)'], ['light', 'rgb(247, 249, 251)']]) {
|
||||||
|
const appearance = document.querySelector('#appearance');
|
||||||
|
appearance.value = theme;
|
||||||
|
appearance.dispatchEvent(new Event('change'));
|
||||||
|
if (getComputedStyle(document.documentElement).backgroundColor !== expected) throw new Error('Theme failed');
|
||||||
|
}
|
||||||
|
input.value = 'https://127.0.0.1:9/';
|
||||||
|
form.requestSubmit();
|
||||||
|
await wait(() => status.dataset.state === 'connectionError');
|
||||||
|
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
|
||||||
|
input.value = 'https://kanbangit.de/';
|
||||||
|
form.requestSubmit();
|
||||||
|
if (document.querySelector('#switch-confirmation').hidden) throw new Error('Missing switch confirmation');
|
||||||
|
document.querySelector('#cancel-switch').click();
|
||||||
|
if (!document.querySelector('#switch-confirmation').hidden) throw new Error('Cancel failed');
|
||||||
|
form.requestSubmit();
|
||||||
|
document.querySelector('#confirm-switch').click();
|
||||||
|
} catch (error) {
|
||||||
|
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"#).unwrap();
|
||||||
|
} else if webview.label().starts_with("workspace-") && payload.url().host_str() == Some("kanbangit.de") {
|
||||||
|
let current_stage = page_stage.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if current_stage == 0 {
|
||||||
|
webview.eval(r#"
|
||||||
|
(async () => {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
if (document.querySelector('#app')?.innerText.trim().length > 100) break;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
|
||||||
|
let denied = true;
|
||||||
|
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
|
||||||
|
try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; }
|
||||||
|
catch (error) { denied = denied && String(error).includes('not allowed'); }
|
||||||
|
}
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||||
|
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||||
|
localStorage.setItem('kan52_fixture', 'isolated');
|
||||||
|
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined';
|
||||||
|
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||||
|
})();
|
||||||
|
"#).unwrap();
|
||||||
|
} else {
|
||||||
|
webview.eval(r#"
|
||||||
|
const isolated = !document.cookie.includes('kan52_fixture') && localStorage.getItem('kan52_fixture') === null;
|
||||||
|
location.href = '/__native_smoke_result?stage=isolated&passed=' + isolated;
|
||||||
|
"#).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_navigation(move |webview, url| {
|
||||||
|
|
||||||
|
if url.path() != "/__native_smoke_result" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
|
||||||
|
let loaded = url.query_pairs().any(|(key, value)| key == "stage" && value == "loaded");
|
||||||
|
println!("macOS WebView smoke test: {}", url.query().unwrap_or_default());
|
||||||
|
if success && loaded {
|
||||||
|
let app = webview.app_handle().clone();
|
||||||
|
let label = webview.label().to_string();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let window = app.get_webview_window(&label).unwrap();
|
||||||
|
// A blocked cross-origin navigation must leave the existing input intact.
|
||||||
|
window.navigate(tauri::Url::parse("https://kanbangit.de:9/unreachable").unwrap()).unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
window.eval(r#"
|
||||||
|
if (document.querySelector('#kan52-unsaved')?.value !== 'unsaved input') {
|
||||||
|
location.href = '/__native_smoke_result?passed=false&stage=preservation';
|
||||||
|
}
|
||||||
|
"#).unwrap();
|
||||||
|
let launcher = app.get_webview_window("launcher").unwrap();
|
||||||
|
launcher.show().unwrap();
|
||||||
|
launcher.eval(r#"
|
||||||
|
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
|
||||||
|
document.querySelector('#server-form').requestSubmit();
|
||||||
|
document.querySelector('#confirm-switch').click();
|
||||||
|
"#).unwrap();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result_passed.store(success, Ordering::SeqCst);
|
||||||
|
webview.app_handle().exit(if success { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
false
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
let mut context = kanbangit_native::context();
|
||||||
|
context.config_mut().identifier = format!("de.kanbangit.smoke.kan52.{}", std::process::id());
|
||||||
|
let app = kanbangit_native::builder()
|
||||||
|
.plugin(plugin)
|
||||||
|
.build(context)
|
||||||
|
.expect("The macOS application must start");
|
||||||
|
let config_dir = app.path().app_config_dir().unwrap();
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||||
|
eprintln!("macOS smoke test timed out");
|
||||||
|
handle.exit(1);
|
||||||
|
});
|
||||||
|
app.run_return(|_, _| {});
|
||||||
|
let _ = std::fs::remove_dir_all(config_dir);
|
||||||
|
assert!(
|
||||||
|
passed.load(Ordering::SeqCst),
|
||||||
|
"The native smoke test must complete successfully"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn main() {
|
||||||
|
panic!("Run this smoke test on macOS with an active graphical session");
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const actions = [];
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
if (url.pathname.startsWith('/action/')) {
|
||||||
|
actions.push(url);
|
||||||
|
response.writeHead(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
'index.html',
|
||||||
|
'main.js',
|
||||||
|
'server.js',
|
||||||
|
'locales/en.js',
|
||||||
|
'styles.css',
|
||||||
|
'mark.svg',
|
||||||
|
].includes(file)
|
||||||
|
) {
|
||||||
|
response.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.setHeader(
|
||||||
|
'Content-Type',
|
||||||
|
file.endsWith('.js')
|
||||||
|
? 'text/javascript'
|
||||||
|
: file.endsWith('.css')
|
||||||
|
? 'text/css'
|
||||||
|
: file.endsWith('.svg')
|
||||||
|
? 'image/svg+xml'
|
||||||
|
: 'text/html',
|
||||||
|
);
|
||||||
|
response.end(await readFile(new URL(`../ui/${file}`, import.meta.url)));
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const state = (phase, ready = false, server = 'https://kanbangit.de/') =>
|
||||||
|
page.evaluate(
|
||||||
|
({ phase, ready, server }) => {
|
||||||
|
window.updateLauncher(
|
||||||
|
new URLSearchParams({
|
||||||
|
phase,
|
||||||
|
ready: String(ready),
|
||||||
|
server,
|
||||||
|
}).toString(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ phase, ready, server },
|
||||||
|
);
|
||||||
|
const latestAction = async (name) => {
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
assert.equal(actions.at(-1)?.pathname, `/action/${name}`);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
||||||
|
await state('idle');
|
||||||
|
for (const width of [320, 390, 1440]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
for (const theme of ['light', 'dark', 'system']) {
|
||||||
|
await page.getByLabel('Appearance').selectOption(theme);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Open workspace',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.getByLabel('Server address').fill('http://example.com/');
|
||||||
|
await page.getByLabel('Server address').press('Enter');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Server address').getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await page.getByLabel('Server address').fill('https://first.example.com/');
|
||||||
|
await page.getByLabel('Server address').press('Enter');
|
||||||
|
await latestAction('open');
|
||||||
|
assert.equal(
|
||||||
|
actions.at(-1).searchParams.get('server'),
|
||||||
|
'https://first.example.com/',
|
||||||
|
);
|
||||||
|
await state('connecting', false, 'https://first.example.com/');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Open workspace', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await state('connectionError', false, 'https://first.example.com/');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Server address').inputValue(),
|
||||||
|
'https://first.example.com/',
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||||
|
await latestAction('open');
|
||||||
|
await state('connected', true, 'https://first.example.com/');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Return to workspace', exact: true })
|
||||||
|
.click();
|
||||||
|
await latestAction('resume');
|
||||||
|
await page
|
||||||
|
.getByLabel('Server address')
|
||||||
|
.fill('https://second.example.com/app');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Open workspace', exact: true })
|
||||||
|
.click();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Switch server', exact: true })
|
||||||
|
.isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await latestAction('resume');
|
||||||
|
await page.getByLabel('Server address').press('Enter');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Switch server', exact: true })
|
||||||
|
.click();
|
||||||
|
await latestAction('open');
|
||||||
|
assert.equal(
|
||||||
|
actions.at(-1).searchParams.get('server'),
|
||||||
|
'https://second.example.com/app/',
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'Launcher browser tests passed: responsive themes, keyboard, validation, offline retry, resume and confirmed switching.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { en } from '../ui/locales/en.js';
|
||||||
|
import { normalizeServer, needsConfirmation } from '../ui/server.js';
|
||||||
|
|
||||||
|
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
test('every launcher text resolves to a nonempty English translation', () => {
|
||||||
|
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
|
||||||
|
assert.equal(typeof en[key], 'string', key);
|
||||||
|
assert.ok(en[key].trim(), key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installation addresses support custom domains, ports and base paths', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeServer(' HTTPS://Example.com:443/kanban '),
|
||||||
|
'https://example.com/kanban/',
|
||||||
|
);
|
||||||
|
assert.equal(normalizeServer('https://[::1]:8443/'), 'https://[::1]:8443/');
|
||||||
|
for (const input of [
|
||||||
|
'http://example.com',
|
||||||
|
'https:example.com',
|
||||||
|
'https://user:secret@example.com',
|
||||||
|
'https://user@example.com',
|
||||||
|
'https://example.com/?token=secret',
|
||||||
|
'https://example.com/#secret',
|
||||||
|
'https://exa\nmple.com',
|
||||||
|
'https://example.com\\other',
|
||||||
|
'file:///tmp/test',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'',
|
||||||
|
]) {
|
||||||
|
assert.throws(() => normalizeServer(input), undefined, input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the installation requires confirmation, retrying does not', () => {
|
||||||
|
assert.equal(needsConfirmation(null, 'https://example.com/'), false);
|
||||||
|
assert.equal(
|
||||||
|
needsConfirmation('https://example.com/', 'https://example.com/'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
needsConfirmation('https://example.com/', 'https://example.com:8443/'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
needsConfirmation('https://example.com/a/', 'https://example.com/b/'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>KanbanGit</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
<script type="module" src="./main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
||||||
|
<p class="eyebrow" data-i18n="appName"></p>
|
||||||
|
<h1 data-i18n="title"></h1>
|
||||||
|
<p class="description" data-i18n="description"></p>
|
||||||
|
<form id="server-form" novalidate>
|
||||||
|
<label for="server-url" data-i18n="serverLabel"></label>
|
||||||
|
<input id="server-url" type="url" inputmode="url" autocomplete="url" spellcheck="false" aria-describedby="server-help connection-status" value="https://kanbangit.de/" required />
|
||||||
|
<p id="server-help" class="description" data-i18n="serverHelp"></p>
|
||||||
|
<button class="open-workspace" type="submit" data-i18n="openWorkspace"></button>
|
||||||
|
</form>
|
||||||
|
<p id="connection-status" class="description" role="status" aria-live="polite"></p>
|
||||||
|
<div id="switch-confirmation" hidden>
|
||||||
|
<p class="description" data-i18n="switchWarning"></p>
|
||||||
|
<button id="confirm-switch" type="button" data-i18n="confirmSwitch"></button>
|
||||||
|
<button id="cancel-switch" type="button" data-i18n="cancel"></button>
|
||||||
|
</div>
|
||||||
|
<button id="resume" type="button" data-i18n="resume" hidden></button>
|
||||||
|
<label class="appearance">
|
||||||
|
<span data-i18n="appearance"></span>
|
||||||
|
<select id="appearance">
|
||||||
|
<option value="system" data-i18n="system"></option>
|
||||||
|
<option value="light" data-i18n="light"></option>
|
||||||
|
<option value="dark" data-i18n="dark"></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const en = {
|
||||||
|
appName: 'KanbanGit',
|
||||||
|
title: 'Choose your workspace.',
|
||||||
|
description: 'Connect to your KanbanGit installation over HTTPS.',
|
||||||
|
serverLabel: 'Server address',
|
||||||
|
serverHelp:
|
||||||
|
'Use the installation address without login details, a query or a fragment.',
|
||||||
|
openWorkspace: 'Open workspace',
|
||||||
|
retry: 'Try again',
|
||||||
|
resume: 'Return to workspace',
|
||||||
|
switchWarning:
|
||||||
|
'Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.',
|
||||||
|
confirmSwitch: 'Switch server',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
idle: '',
|
||||||
|
connecting: 'Connecting to your server…',
|
||||||
|
connected:
|
||||||
|
'Your workspace is open. Use KanbanGit → Server settings to change servers.',
|
||||||
|
connectionError:
|
||||||
|
'The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.',
|
||||||
|
invalidServer:
|
||||||
|
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
|
||||||
|
storageError:
|
||||||
|
'The server setting could not be read or saved. Check your local storage permissions and try again.',
|
||||||
|
appearance: 'Appearance',
|
||||||
|
system: 'System',
|
||||||
|
light: 'Light',
|
||||||
|
dark: 'Dark',
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { en } from './locales/en.js';
|
||||||
|
import { normalizeServer, needsConfirmation } from './server.js';
|
||||||
|
|
||||||
|
for (const element of document.querySelectorAll('[data-i18n]')) {
|
||||||
|
element.textContent = en[element.dataset.i18n];
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.querySelector('#server-form');
|
||||||
|
const input = document.querySelector('#server-url');
|
||||||
|
const status = document.querySelector('#connection-status');
|
||||||
|
const open = document.querySelector('.open-workspace');
|
||||||
|
const resume = document.querySelector('#resume');
|
||||||
|
const confirmation = document.querySelector('#switch-confirmation');
|
||||||
|
let activeServer = null;
|
||||||
|
let pendingServer = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
function action(name, server) {
|
||||||
|
const url = new URL(`/action/${name}`, location.href);
|
||||||
|
if (server) {
|
||||||
|
url.searchParams.set('server', server);
|
||||||
|
}
|
||||||
|
location.href = url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateLauncher = (query) => {
|
||||||
|
const state = new URLSearchParams(query);
|
||||||
|
const phase = state.get('phase');
|
||||||
|
const server = state.get('server');
|
||||||
|
if (!initialized) {
|
||||||
|
input.value = server;
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
if (phase === 'connecting' || phase === 'connected') {
|
||||||
|
activeServer = server;
|
||||||
|
}
|
||||||
|
status.textContent = en[phase] ?? '';
|
||||||
|
status.dataset.state = phase;
|
||||||
|
open.disabled = phase === 'connecting';
|
||||||
|
open.textContent =
|
||||||
|
en[phase === 'connectionError' ? 'retry' : 'openWorkspace'];
|
||||||
|
resume.hidden = state.get('ready') !== 'true';
|
||||||
|
form.setAttribute('aria-busy', String(phase === 'connecting'));
|
||||||
|
};
|
||||||
|
|
||||||
|
function connect(server) {
|
||||||
|
confirmation.hidden = true;
|
||||||
|
pendingServer = null;
|
||||||
|
action('open', server);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
let server;
|
||||||
|
try {
|
||||||
|
server = normalizeServer(input.value);
|
||||||
|
} catch {
|
||||||
|
status.textContent = en.invalidServer;
|
||||||
|
input.setAttribute('aria-invalid', 'true');
|
||||||
|
input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.removeAttribute('aria-invalid');
|
||||||
|
if (needsConfirmation(activeServer, server)) {
|
||||||
|
pendingServer = server;
|
||||||
|
confirmation.hidden = false;
|
||||||
|
document.querySelector('#confirm-switch').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#confirm-switch').addEventListener('click', () => {
|
||||||
|
if (pendingServer) {
|
||||||
|
connect(pendingServer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelector('#cancel-switch').addEventListener('click', () => {
|
||||||
|
confirmation.hidden = true;
|
||||||
|
pendingServer = null;
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
resume.addEventListener('click', () => action('resume'));
|
||||||
|
|
||||||
|
document.querySelector('#appearance').addEventListener('change', (event) => {
|
||||||
|
document.documentElement.dataset.appearance = event.target.value;
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#22c55e" />
|
||||||
|
<g fill="none" stroke="#004b1e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="16" y="16" width="32" height="32" rx="3" />
|
||||||
|
<path d="M27 16v32M37 16v32" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 363 B |
@@ -0,0 +1,33 @@
|
|||||||
|
export function normalizeServer(input) {
|
||||||
|
const value = input.trim();
|
||||||
|
if (
|
||||||
|
!/^https:\/\//i.test(value) ||
|
||||||
|
[...value].some(
|
||||||
|
(character) =>
|
||||||
|
character.charCodeAt(0) < 32 ||
|
||||||
|
character.charCodeAt(0) === 127 ||
|
||||||
|
character === '\\',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('invalidServer');
|
||||||
|
}
|
||||||
|
const url = new URL(value);
|
||||||
|
if (
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
!url.hostname ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
value.includes('?') ||
|
||||||
|
value.includes('#')
|
||||||
|
) {
|
||||||
|
throw new Error('invalidServer');
|
||||||
|
}
|
||||||
|
if (!url.pathname.endsWith('/')) {
|
||||||
|
url.pathname += '/';
|
||||||
|
}
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsConfirmation(activeServer, nextServer) {
|
||||||
|
return activeServer !== null && activeServer !== nextServer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
:root {
|
||||||
|
--surface: #f7f9fb;
|
||||||
|
--foreground: #191c1e;
|
||||||
|
--muted: #505f76;
|
||||||
|
--primary: #006e2f;
|
||||||
|
--border: #bccbb9;
|
||||||
|
--control: #ffffff;
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--surface);
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-appearance='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-appearance='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #0f172a;
|
||||||
|
--foreground: #f8fafc;
|
||||||
|
--muted: #b7c8e1;
|
||||||
|
--primary: #4ae176;
|
||||||
|
--border: #334155;
|
||||||
|
--control: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-appearance='light']) {
|
||||||
|
--surface: #0f172a;
|
||||||
|
--foreground: #f8fafc;
|
||||||
|
--muted: #b7c8e1;
|
||||||
|
--primary: #4ae176;
|
||||||
|
--border: #334155;
|
||||||
|
--control: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(100%, 380px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description,
|
||||||
|
.server,
|
||||||
|
.appearance {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-workspace {
|
||||||
|
display: block;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #004b1e;
|
||||||
|
background: #22c55e;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-workspace:hover {
|
||||||
|
background: #4ae176;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font: inherit;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--control);
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--control);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-workspace {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#switch-confirmation {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useForm } from '@inertiajs/vue3';
|
|
||||||
import { computed, watch } from 'vue';
|
|
||||||
import {
|
|
||||||
updateTeamPermissions,
|
|
||||||
updateUserPermissions,
|
|
||||||
} from '@/actions/App/Http/Controllers/ProjectController';
|
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import type { PermissionOption } from '@/types';
|
|
||||||
import {
|
|
||||||
projectPermissionDescriptions,
|
|
||||||
projectPermissionLabels,
|
|
||||||
} from '@/types/project-access';
|
|
||||||
import type { ProjectAccessTarget } from '@/types/project-access';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
projectId: number;
|
|
||||||
target: ProjectAccessTarget | null;
|
|
||||||
permissionOptions: PermissionOption[];
|
|
||||||
}>();
|
|
||||||
const emit = defineEmits<{ close: [] }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const form = useForm({ permissions: [] as string[] });
|
|
||||||
const protection = useFormProtection(form, {
|
|
||||||
isDirty: () => props.target !== null && form.isDirty,
|
|
||||||
});
|
|
||||||
const selectionError = computed(
|
|
||||||
() =>
|
|
||||||
Object.entries(form.errors).find(([key]) =>
|
|
||||||
key.startsWith('permissions'),
|
|
||||||
)?.[1],
|
|
||||||
);
|
|
||||||
const options = computed(() =>
|
|
||||||
props.permissionOptions
|
|
||||||
.filter((option) => option.value in projectPermissionLabels)
|
|
||||||
.map((option) => ({
|
|
||||||
value: option.value,
|
|
||||||
label: t(
|
|
||||||
projectPermissionLabels[
|
|
||||||
option.value as keyof typeof projectPermissionLabels
|
|
||||||
],
|
|
||||||
),
|
|
||||||
description: t(
|
|
||||||
projectPermissionDescriptions[
|
|
||||||
option.value as keyof typeof projectPermissionDescriptions
|
|
||||||
],
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
watch(
|
|
||||||
() => props.target,
|
|
||||||
(target) => {
|
|
||||||
form.defaults({ permissions: [...(target?.permissions ?? [])] });
|
|
||||||
form.resetAndClearErrors();
|
|
||||||
protection.clearFeedback();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
function close(open: boolean): void {
|
|
||||||
if (open || form.processing || !protection.confirmLeave()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.resetAndClearErrors();
|
|
||||||
emit('close');
|
|
||||||
}
|
|
||||||
function submit(): void {
|
|
||||||
if (!props.target || form.processing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const route =
|
|
||||||
props.target.kind === 'user'
|
|
||||||
? updateUserPermissions([props.projectId, props.target.id])
|
|
||||||
: updateTeamPermissions([props.projectId, props.target.id]);
|
|
||||||
protection.submit(route, {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
form.defaults();
|
|
||||||
form.resetAndClearErrors();
|
|
||||||
emit('close');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog :open="target !== null" @update:open="close">
|
|
||||||
<DialogContent
|
|
||||||
:show-close-button="!form.processing"
|
|
||||||
@escape-key-down="form.processing && $event.preventDefault()"
|
|
||||||
@interact-outside="form.processing && $event.preventDefault()"
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle class="break-words">{{
|
|
||||||
t('projectAccess.editFor', { name: target?.name ?? '' })
|
|
||||||
}}</DialogTitle>
|
|
||||||
<DialogDescription>{{
|
|
||||||
t(
|
|
||||||
target?.kind === 'team'
|
|
||||||
? 'projectAccess.teamHelp'
|
|
||||||
: 'projectAccess.directHelp',
|
|
||||||
)
|
|
||||||
}}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
|
||||||
<FormFeedback
|
|
||||||
:state="protection.state.value"
|
|
||||||
:title="
|
|
||||||
protection.responseStatus.value === 403
|
|
||||||
? t('projectAccess.forbidden')
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:description="
|
|
||||||
protection.responseStatus.value === 403
|
|
||||||
? t('projectAccess.forbiddenHelp')
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:retryable="protection.responseStatus.value !== 403"
|
|
||||||
@retry="submit"
|
|
||||||
/>
|
|
||||||
<fieldset
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="grid min-w-0 gap-3"
|
|
||||||
:aria-invalid="!!selectionError"
|
|
||||||
aria-describedby="project-permission-error"
|
|
||||||
>
|
|
||||||
<legend class="mb-3 text-sm font-semibold">
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
target?.kind === 'team'
|
|
||||||
? 'projectAccess.teamPermissions'
|
|
||||||
: 'projectAccess.directPermissions',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</legend>
|
|
||||||
<label
|
|
||||||
v-for="option in options"
|
|
||||||
:key="option.value"
|
|
||||||
class="hover:bg-accent flex cursor-pointer items-start gap-3 rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.permissions"
|
|
||||||
type="checkbox"
|
|
||||||
:value="option.value"
|
|
||||||
:aria-label="option.label"
|
|
||||||
class="accent-primary focus-visible:ring-ring mt-0.5 size-5 shrink-0 focus-visible:ring-2"
|
|
||||||
/>
|
|
||||||
<span class="grid gap-1"
|
|
||||||
><span class="text-sm font-medium">{{
|
|
||||||
option.label
|
|
||||||
}}</span
|
|
||||||
><span class="text-muted-foreground text-xs">{{
|
|
||||||
option.description
|
|
||||||
}}</span></span
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
<InputError
|
|
||||||
id="project-permission-error"
|
|
||||||
:message="selectionError"
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('projectAccess.clearHelp') }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="target?.globalAdmin"
|
|
||||||
class="bg-accent text-accent-foreground rounded-lg p-3 text-sm"
|
|
||||||
>
|
|
||||||
{{ t('projectAccess.globalHelp') }}
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
{{ t('projectAccess.selfHelp') }}
|
|
||||||
</p>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="form.processing"
|
|
||||||
@click="close(false)"
|
|
||||||
>{{ t('common.cancel') }}</Button
|
|
||||||
>
|
|
||||||
<Button type="submit" :disabled="form.processing">{{
|
|
||||||
t('projectAccess.save')
|
|
||||||
}}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { projectPermissionLabels } from '@/types/project-access';
|
|
||||||
|
|
||||||
defineProps<{ permissions: string[]; emptyLabel: string }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<template
|
|
||||||
v-for="(label, value) in projectPermissionLabels"
|
|
||||||
:key="value"
|
|
||||||
>
|
|
||||||
<Badge v-if="permissions.includes(value)" variant="secondary">{{
|
|
||||||
t(label)
|
|
||||||
}}</Badge>
|
|
||||||
</template>
|
|
||||||
<span
|
|
||||||
v-if="permissions.length === 0"
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>{{ emptyLabel }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useForm } from '@inertiajs/vue3';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import {
|
|
||||||
store,
|
|
||||||
updateMembers,
|
|
||||||
updateUserPermissions,
|
|
||||||
} from '@/actions/App/Http/Controllers/TeamController';
|
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import type { TeamEditorProps } from '@/types/teams';
|
|
||||||
|
|
||||||
const props = defineProps<TeamEditorProps>();
|
|
||||||
const emit = defineEmits<{ close: [] }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const search = ref('');
|
|
||||||
const userId = ref<number | null>(null);
|
|
||||||
const form = useForm({
|
|
||||||
name: '',
|
|
||||||
user_ids: [] as number[],
|
|
||||||
permissions: [] as string[],
|
|
||||||
});
|
|
||||||
const protection = useFormProtection(form, {
|
|
||||||
isDirty: () => props.target !== null && form.isDirty,
|
|
||||||
});
|
|
||||||
const team = computed(() =>
|
|
||||||
props.target && props.target.kind !== 'create' ? props.target.team : null,
|
|
||||||
);
|
|
||||||
const visibleUsers = computed(() =>
|
|
||||||
props.users.filter((user) =>
|
|
||||||
`${user.name} ${user.email}`
|
|
||||||
.toLocaleLowerCase()
|
|
||||||
.includes(search.value.trim().toLocaleLowerCase()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const title = computed(() =>
|
|
||||||
t(
|
|
||||||
props.target?.kind === 'create'
|
|
||||||
? 'teams.create'
|
|
||||||
: props.target?.kind === 'members'
|
|
||||||
? 'teams.manageMembers'
|
|
||||||
: 'teams.permissions',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const selectionError = computed(
|
|
||||||
() =>
|
|
||||||
Object.entries(form.errors).find(([key]) =>
|
|
||||||
key.startsWith(
|
|
||||||
props.target?.kind === 'members' ? 'user_ids' : 'permissions',
|
|
||||||
),
|
|
||||||
)?.[1],
|
|
||||||
);
|
|
||||||
const permissionLabels = {
|
|
||||||
'team:invite': 'teams.permissionInvite',
|
|
||||||
'team:manage': 'teams.permissionManage',
|
|
||||||
'team:admin': 'teams.permissionAdmin',
|
|
||||||
} as const;
|
|
||||||
function permissionLabel(value: string, fallback: string): string {
|
|
||||||
return value in permissionLabels
|
|
||||||
? t(permissionLabels[value as keyof typeof permissionLabels])
|
|
||||||
: fallback;
|
|
||||||
}
|
|
||||||
watch(
|
|
||||||
() => props.target,
|
|
||||||
() => {
|
|
||||||
userId.value = null;
|
|
||||||
search.value = '';
|
|
||||||
form.defaults({
|
|
||||||
name: '',
|
|
||||||
user_ids: [...(team.value?.user_ids ?? [])],
|
|
||||||
permissions: [],
|
|
||||||
});
|
|
||||||
form.resetAndClearErrors();
|
|
||||||
protection.clearFeedback();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
function chooseUser(event: Event): void {
|
|
||||||
const select = event.target as HTMLSelectElement;
|
|
||||||
if (!protection.confirmLeave()) {
|
|
||||||
select.value = String(userId.value ?? '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userId.value = select.value ? Number(select.value) : null;
|
|
||||||
form.defaults('permissions', [
|
|
||||||
...(props.teamPermissions[team.value?.id ?? '']?.[userId.value ?? ''] ??
|
|
||||||
[]),
|
|
||||||
]);
|
|
||||||
form.resetAndClearErrors('permissions');
|
|
||||||
protection.clearFeedback();
|
|
||||||
}
|
|
||||||
function close(open: boolean): void {
|
|
||||||
if (open || form.processing || !protection.confirmLeave()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.resetAndClearErrors();
|
|
||||||
emit('close');
|
|
||||||
}
|
|
||||||
function submit(): void {
|
|
||||||
const target = props.target;
|
|
||||||
if (
|
|
||||||
!target ||
|
|
||||||
form.processing ||
|
|
||||||
(target.kind === 'permissions' && userId.value === null)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const route =
|
|
||||||
target.kind === 'create'
|
|
||||||
? store()
|
|
||||||
: target.kind === 'members'
|
|
||||||
? updateMembers(target.team.id)
|
|
||||||
: updateUserPermissions([target.team.id, userId.value!]);
|
|
||||||
form.transform((data) =>
|
|
||||||
target.kind === 'create'
|
|
||||||
? { name: data.name }
|
|
||||||
: target.kind === 'members'
|
|
||||||
? { user_ids: data.user_ids }
|
|
||||||
: { permissions: data.permissions },
|
|
||||||
);
|
|
||||||
protection.submit(route, {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
form.resetAndClearErrors();
|
|
||||||
emit('close');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog :open="target !== null" @update:open="close">
|
|
||||||
<DialogContent
|
|
||||||
:show-close-button="!form.processing"
|
|
||||||
@escape-key-down="form.processing && $event.preventDefault()"
|
|
||||||
@interact-outside="form.processing && $event.preventDefault()"
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{{ title }}</DialogTitle>
|
|
||||||
<DialogDescription class="break-words">{{
|
|
||||||
team?.name ?? t('teams.createDescription')
|
|
||||||
}}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
|
||||||
<FormFeedback
|
|
||||||
:state="protection.state.value"
|
|
||||||
:title="
|
|
||||||
protection.responseStatus.value === 403
|
|
||||||
? t('teams.forbidden')
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:description="
|
|
||||||
protection.responseStatus.value === 403
|
|
||||||
? t('teams.forbiddenDescription')
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:retryable="protection.responseStatus.value !== 403"
|
|
||||||
@retry="submit"
|
|
||||||
/>
|
|
||||||
<fieldset
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="grid min-w-0 gap-4"
|
|
||||||
>
|
|
||||||
<template v-if="target?.kind === 'create'">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="team-name">{{ t('teams.name') }}</Label>
|
|
||||||
<Input
|
|
||||||
id="team-name"
|
|
||||||
v-model="form.name"
|
|
||||||
required
|
|
||||||
maxlength="255"
|
|
||||||
:placeholder="t('teams.namePlaceholder')"
|
|
||||||
:aria-invalid="!!form.errors.name"
|
|
||||||
aria-describedby="team-name-error"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
id="team-name-error"
|
|
||||||
:message="form.errors.name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="target?.kind === 'members'">
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('teams.membersHelp') }}
|
|
||||||
</p>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="member-search">{{
|
|
||||||
t('teams.searchPeople')
|
|
||||||
}}</Label>
|
|
||||||
<Input
|
|
||||||
id="member-search"
|
|
||||||
v-model="search"
|
|
||||||
type="search"
|
|
||||||
:placeholder="
|
|
||||||
t('teams.searchPeoplePlaceholder')
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted-foreground text-sm" role="status">
|
|
||||||
{{
|
|
||||||
t('teams.selected', {
|
|
||||||
count: form.user_ids.length,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="grid max-h-64 gap-1 overflow-y-auto rounded-md border p-2"
|
|
||||||
role="group"
|
|
||||||
:aria-label="t('teams.members')"
|
|
||||||
:aria-invalid="!!selectionError"
|
|
||||||
aria-describedby="team-selection-error"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
v-for="user in visibleUsers"
|
|
||||||
:key="user.id"
|
|
||||||
class="hover:bg-accent flex min-w-0 cursor-pointer items-center gap-3 rounded-md p-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.user_ids"
|
|
||||||
type="checkbox"
|
|
||||||
:value="user.id"
|
|
||||||
class="accent-primary focus-visible:ring-ring size-5 shrink-0 focus-visible:ring-2"
|
|
||||||
/>
|
|
||||||
<UserAvatar
|
|
||||||
:user="user"
|
|
||||||
class="size-8 shrink-0"
|
|
||||||
/>
|
|
||||||
<span class="grid min-w-0 gap-0.5 text-sm"
|
|
||||||
><span class="break-words font-medium">{{
|
|
||||||
user.name
|
|
||||||
}}</span
|
|
||||||
><span
|
|
||||||
class="text-muted-foreground break-all text-xs"
|
|
||||||
>{{ user.email }}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
<p
|
|
||||||
v-if="visibleUsers.length === 0"
|
|
||||||
class="text-muted-foreground p-3 text-sm"
|
|
||||||
>
|
|
||||||
{{ t('teams.noPeople') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<InputError
|
|
||||||
id="team-selection-error"
|
|
||||||
:message="selectionError"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="target?.kind === 'permissions'">
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('teams.permissionsHelp') }}
|
|
||||||
</p>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="permission-user">{{
|
|
||||||
t('teams.person')
|
|
||||||
}}</Label>
|
|
||||||
<select
|
|
||||||
id="permission-user"
|
|
||||||
:value="userId ?? ''"
|
|
||||||
class="border-input bg-background focus-visible:ring-ring h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
|
|
||||||
@change="chooseUser"
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
{{ t('teams.choosePerson') }}
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.name }} ({{ user.email }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<fieldset
|
|
||||||
v-if="userId !== null"
|
|
||||||
class="grid gap-2"
|
|
||||||
:aria-invalid="!!selectionError"
|
|
||||||
aria-describedby="team-selection-error"
|
|
||||||
>
|
|
||||||
<legend class="mb-2 text-sm font-medium">
|
|
||||||
{{ t('teams.directPermissions') }}
|
|
||||||
</legend>
|
|
||||||
<label
|
|
||||||
v-for="option in permissionOptions"
|
|
||||||
:key="option.value"
|
|
||||||
class="hover:bg-accent flex cursor-pointer items-center gap-3 rounded-md border p-3 text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.permissions"
|
|
||||||
type="checkbox"
|
|
||||||
:value="option.value"
|
|
||||||
class="accent-primary focus-visible:ring-ring size-5 shrink-0 focus-visible:ring-2"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
permissionLabel(option.value, option.label)
|
|
||||||
}}
|
|
||||||
</label>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('teams.permissionsClearHelp') }}
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
<InputError
|
|
||||||
id="team-selection-error"
|
|
||||||
:message="selectionError"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</fieldset>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="form.processing"
|
|
||||||
@click="close(false)"
|
|
||||||
>{{ t('common.cancel') }}</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:disabled="
|
|
||||||
form.processing ||
|
|
||||||
(target?.kind === 'permissions' && userId === null)
|
|
||||||
"
|
|
||||||
>{{
|
|
||||||
t(
|
|
||||||
target?.kind === 'create'
|
|
||||||
? 'teams.create'
|
|
||||||
: target?.kind === 'members'
|
|
||||||
? 'teams.saveMembers'
|
|
||||||
: 'teams.savePermissions',
|
|
||||||
)
|
|
||||||
}}</Button
|
|
||||||
>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
@@ -1,58 +1,4 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
'projectAccess.pageTitle': '{project} · Project access',
|
|
||||||
'projectAccess.title': 'Project access',
|
|
||||||
'projectAccess.description':
|
|
||||||
'Manage who can access {project} and see where their permissions come from.',
|
|
||||||
'projectAccess.back': 'Back to project',
|
|
||||||
'projectAccess.saved': 'Project permissions saved.',
|
|
||||||
'projectAccess.howItWorks': 'How project access works',
|
|
||||||
'projectAccess.additiveHelp':
|
|
||||||
'Direct user permissions and permissions from every team are combined. Write includes read; administrator includes write and read.',
|
|
||||||
'projectAccess.limitsHelp':
|
|
||||||
'Project administrators can change project assignments here. Global permissions and team membership are managed separately.',
|
|
||||||
'projectAccess.search': 'Search people or teams',
|
|
||||||
'projectAccess.people': 'People and effective access',
|
|
||||||
'projectAccess.effective': 'Effective permissions',
|
|
||||||
'projectAccess.noAccess': 'No project access',
|
|
||||||
'projectAccess.globalSource':
|
|
||||||
'Global administrator · all project permissions',
|
|
||||||
'projectAccess.direct': 'Direct:',
|
|
||||||
'projectAccess.none': 'None',
|
|
||||||
'projectAccess.viaTeam': 'Via {name}:',
|
|
||||||
'projectAccess.editFor': 'Edit access for {name}',
|
|
||||||
'projectAccess.editDirect': 'Edit direct access',
|
|
||||||
'projectAccess.editTeam': 'Edit team access',
|
|
||||||
'projectAccess.teamPermissions': 'Team permissions',
|
|
||||||
'projectAccess.directPermissions': 'Direct permissions',
|
|
||||||
'projectAccess.teamHelp':
|
|
||||||
'These project permissions apply to every current and future member of the team.',
|
|
||||||
'projectAccess.directHelp':
|
|
||||||
'Change only this person’s direct project permissions. Team and global permissions continue to apply.',
|
|
||||||
'projectAccess.noPeopleResults': 'No matching people',
|
|
||||||
'projectAccess.noPeople': 'No people available',
|
|
||||||
'projectAccess.noTeamResults': 'No matching teams',
|
|
||||||
'projectAccess.noTeams': 'No teams yet',
|
|
||||||
'projectAccess.noTeamsHelp':
|
|
||||||
'Create a team and add members before assigning shared project access.',
|
|
||||||
'projectAccess.noTeamAccess': 'No project permissions assigned',
|
|
||||||
'projectAccess.read': 'Read',
|
|
||||||
'projectAccess.write': 'Write',
|
|
||||||
'projectAccess.admin': 'Administrator',
|
|
||||||
'projectAccess.readHelp': 'View the project and its tickets.',
|
|
||||||
'projectAccess.writeHelp': 'Create and edit tickets. Includes read access.',
|
|
||||||
'projectAccess.adminHelp':
|
|
||||||
'Manage project access and project administration. Includes write and read access.',
|
|
||||||
'projectAccess.clearHelp':
|
|
||||||
'Clear all selections to remove this assignment. Access granted by other teams, direct assignments or global administration is preserved.',
|
|
||||||
'projectAccess.globalHelp':
|
|
||||||
'This person is a global administrator. Changing these project assignments cannot remove their global access.',
|
|
||||||
'projectAccess.selfHelp':
|
|
||||||
'If you remove your own administrator access, you will return to Projects.',
|
|
||||||
'projectAccess.save': 'Save permissions',
|
|
||||||
'projectAccess.forbidden': 'Project access can no longer be changed',
|
|
||||||
'projectAccess.forbiddenHelp':
|
|
||||||
'Your administrator permission may have changed. Close this dialog and reopen the project to check your current access.',
|
|
||||||
|
|
||||||
'notifications.title': 'Notifications',
|
'notifications.title': 'Notifications',
|
||||||
'notifications.pageTitle': '{project} · Notifications',
|
'notifications.pageTitle': '{project} · Notifications',
|
||||||
'notifications.description':
|
'notifications.description':
|
||||||
@@ -226,52 +172,6 @@ export const en = {
|
|||||||
'templates.deleteImpact':
|
'templates.deleteImpact':
|
||||||
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
|
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
|
||||||
|
|
||||||
'teams.eyebrow': 'People & access',
|
|
||||||
'teams.description':
|
|
||||||
'Bring people together in reusable teams. Manage membership and the permissions to administer each team.',
|
|
||||||
'teams.create': 'Create team',
|
|
||||||
'teams.createDescription':
|
|
||||||
'Group people into a team to share project access.',
|
|
||||||
'teams.name': 'Team name',
|
|
||||||
'teams.namePlaceholder': 'Platform team',
|
|
||||||
'teams.yourTeams': 'Teams you manage',
|
|
||||||
'teams.search': 'Search teams',
|
|
||||||
'teams.clearSearch': 'Clear search',
|
|
||||||
'teams.empty': 'No teams to manage',
|
|
||||||
'teams.emptyCreate': 'Create a team to start organizing your people.',
|
|
||||||
'teams.emptyDescription':
|
|
||||||
'No teams are available for your current permissions.',
|
|
||||||
'teams.noResults': 'No matching teams',
|
|
||||||
'teams.noResultsDescription': 'Try another team name or clear your search.',
|
|
||||||
'teams.manageMembers': 'Manage members',
|
|
||||||
'teams.manageMembersFor': 'Manage members of {name}',
|
|
||||||
'teams.permissionsFor': 'Manage permissions for {name}',
|
|
||||||
'teams.members': 'Members',
|
|
||||||
'teams.noMembers': 'No members yet.',
|
|
||||||
'teams.membersHelp':
|
|
||||||
'Select the people who belong to this team. Changes apply when you save.',
|
|
||||||
'teams.selected': '{count} selected',
|
|
||||||
'teams.searchPeople': 'Find people',
|
|
||||||
'teams.searchPeoplePlaceholder': 'Search by name or email',
|
|
||||||
'teams.noPeople': 'No matching people.',
|
|
||||||
'teams.saveMembers': 'Save members',
|
|
||||||
'teams.permissions': 'Team permissions',
|
|
||||||
'teams.permissionsHelp':
|
|
||||||
'Direct team permissions are separate from membership. Choose any user to review or change their assignments.',
|
|
||||||
'teams.person': 'User',
|
|
||||||
'teams.choosePerson': 'Choose a user',
|
|
||||||
'teams.directPermissions': 'Direct permissions',
|
|
||||||
'teams.permissionInvite': 'Team invite',
|
|
||||||
'teams.permissionManage': 'Team manage',
|
|
||||||
'teams.permissionAdmin': 'Team admin',
|
|
||||||
'teams.permissionsClearHelp':
|
|
||||||
'Clear every checkbox to remove direct permissions. Other existing access remains in effect.',
|
|
||||||
'teams.savePermissions': 'Save permissions',
|
|
||||||
'teams.updated': 'Team changes saved.',
|
|
||||||
'teams.forbidden': 'You no longer have permission',
|
|
||||||
'teams.forbiddenDescription':
|
|
||||||
'Your changes have not been saved. Close this dialog and reload the page to check your current access.',
|
|
||||||
'teams.memberCount': { one: '{count} member', other: '{count} members' },
|
|
||||||
'workflow.title': 'Board workflow',
|
'workflow.title': 'Board workflow',
|
||||||
'workflow.pageTitle': '{project} · Board workflow',
|
'workflow.pageTitle': '{project} · Board workflow',
|
||||||
'workflow.description':
|
'workflow.description':
|
||||||
|
|||||||
@@ -1,344 +1,308 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
import { ArrowLeft, Search, ShieldCheck, Users } from 'lucide-vue-next';
|
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
import { computed, ref } from 'vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import ProjectAccessEditorDialog from '@/components/projects/ProjectAccessEditorDialog.vue';
|
|
||||||
import ProjectPermissionBadges from '@/components/projects/ProjectPermissionBadges.vue';
|
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import {
|
||||||
import { Label } from '@/components/ui/label';
|
Card,
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { access, index, show } from '@/routes/projects';
|
import { access, index, show } from '@/routes/projects/index';
|
||||||
import { general } from '@/routes/projects/settings';
|
import { general as generalSettings } from '@/routes/projects/settings';
|
||||||
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
||||||
import type { ProjectAccessTarget } from '@/types/project-access';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectAccessPageProps>();
|
const props = defineProps<ProjectAccessPageProps>();
|
||||||
const page = usePage();
|
|
||||||
const { t } = useLocalization();
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
const search = ref('');
|
{
|
||||||
const target = ref<ProjectAccessTarget | null>(null);
|
title: 'Projects',
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
href: index(),
|
||||||
{ title: t('navigation.projects'), href: index() },
|
},
|
||||||
{ title: props.project.name, href: show(props.project.id) },
|
{
|
||||||
{ title: t('navigation.access'), href: access(props.project.id) },
|
title: props.project.name,
|
||||||
]);
|
href: show(props.project.id),
|
||||||
const matches = (value: string) =>
|
},
|
||||||
value.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase());
|
{
|
||||||
const visibleUsers = computed(() =>
|
title: 'Access',
|
||||||
props.users.filter((user) => matches(`${user.name} ${user.email}`)),
|
href: access(props.project.id),
|
||||||
);
|
},
|
||||||
const visibleTeams = computed(() =>
|
];
|
||||||
props.teams.filter((team) => matches(team.name)),
|
|
||||||
);
|
const hasPermission = (
|
||||||
const inheritedTeams = (userId: number) =>
|
permissions: Record<string, string[]>,
|
||||||
props.teams.filter(
|
actorId: number,
|
||||||
(team) =>
|
permission: string,
|
||||||
team.user_ids.includes(userId) &&
|
) => (permissions[actorId] ?? []).includes(permission);
|
||||||
(props.teamPermissions[team.id] ?? []).length > 0,
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head
|
<Head :title="`${project.name} access`" />
|
||||||
:title="t('projectAccess.pageTitle', { project: project.name })"
|
|
||||||
/>
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
<main
|
<div
|
||||||
class="mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="grid min-w-0 gap-2">
|
<div class="space-y-3">
|
||||||
<p
|
<Badge variant="outline" class="px-3 py-1">
|
||||||
class="text-primary break-all text-xs font-semibold uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
{{ project.key }}
|
{{ project.key }}
|
||||||
</p>
|
</Badge>
|
||||||
<h1 class="text-headline-mobile sm:text-headline">
|
<Heading
|
||||||
{{ t('projectAccess.title') }}
|
:title="`${project.name} access`"
|
||||||
</h1>
|
description="Assign direct user permissions or inherited team permissions for this project."
|
||||||
<p
|
/>
|
||||||
class="text-muted-foreground max-w-2xl break-words text-sm"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('projectAccess.description', {
|
|
||||||
project: project.name,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 flex-wrap gap-2">
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
<Button
|
<Button
|
||||||
v-if="page.props.auth.permissions.globalAdmin"
|
v-if="$page.props.auth.permissions.globalAdmin"
|
||||||
as-child
|
as-child
|
||||||
variant="outline"
|
variant="outline"
|
||||||
><Link :href="general(project.id)">{{
|
|
||||||
t('navigation.general')
|
|
||||||
}}</Link></Button
|
|
||||||
>
|
|
||||||
<Button as-child variant="outline"
|
|
||||||
><Link :href="show(project.id)"
|
|
||||||
><ArrowLeft class="size-4" aria-hidden="true" />{{
|
|
||||||
t('projectAccess.back')
|
|
||||||
}}</Link
|
|
||||||
></Button
|
|
||||||
>
|
>
|
||||||
|
<Link :href="generalSettings(project.id)">
|
||||||
|
General settings
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="show(project.id)">Back to project</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
<FeedbackState
|
|
||||||
v-if="status"
|
|
||||||
state="success"
|
|
||||||
:title="t('projectAccess.saved')"
|
|
||||||
/>
|
|
||||||
<aside
|
|
||||||
class="bg-accent/60 flex items-start gap-3 rounded-xl border p-4"
|
|
||||||
:aria-label="t('projectAccess.howItWorks')"
|
|
||||||
>
|
|
||||||
<ShieldCheck
|
|
||||||
class="text-primary mt-0.5 size-5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div class="grid gap-1">
|
|
||||||
<h2 class="text-sm font-semibold">
|
|
||||||
{{ t('projectAccess.howItWorks') }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('projectAccess.additiveHelp') }}
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('projectAccess.limitsHelp') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div class="relative sm:max-w-md">
|
|
||||||
<Label for="access-search" class="sr-only">{{
|
|
||||||
t('projectAccess.search')
|
|
||||||
}}</Label
|
|
||||||
><Search
|
|
||||||
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
/><Input
|
|
||||||
id="access-search"
|
|
||||||
v-model="search"
|
|
||||||
type="search"
|
|
||||||
class="pl-9"
|
|
||||||
:placeholder="t('projectAccess.search')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<section
|
|
||||||
class="grid min-w-0 gap-4"
|
<div
|
||||||
aria-labelledby="people-heading"
|
v-if="status"
|
||||||
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
{{ status }}
|
||||||
<h2 id="people-heading" class="text-lg font-semibold">
|
</div>
|
||||||
{{ t('projectAccess.people') }}
|
|
||||||
</h2>
|
<Card class="gap-0 overflow-hidden">
|
||||||
<Badge variant="secondary">{{ users.length }}</Badge>
|
<CardHeader class="border-b">
|
||||||
</div>
|
<CardTitle>User permissions</CardTitle>
|
||||||
<FeedbackState
|
<CardDescription>
|
||||||
v-if="visibleUsers.length === 0"
|
Direct assignments apply only to the selected user
|
||||||
state="empty"
|
inside this project.
|
||||||
:title="
|
</CardDescription>
|
||||||
t(
|
</CardHeader>
|
||||||
users.length
|
|
||||||
? 'projectAccess.noPeopleResults'
|
<CardContent class="p-0">
|
||||||
: 'projectAccess.noPeople',
|
<div class="overflow-x-auto">
|
||||||
)
|
<table
|
||||||
"
|
class="divide-border min-w-full divide-y text-sm"
|
||||||
/>
|
|
||||||
<ul
|
|
||||||
v-else
|
|
||||||
class="bg-card min-w-0 divide-y rounded-xl border"
|
|
||||||
:aria-label="t('projectAccess.people')"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="user in visibleUsers"
|
|
||||||
:key="user.id"
|
|
||||||
class="grid min-w-0 gap-4 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_auto] lg:items-center"
|
|
||||||
:data-user-id="user.id"
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
|
||||||
<UserAvatar :user="user" class="size-10 shrink-0" />
|
|
||||||
<div class="grid min-w-0 gap-1">
|
|
||||||
<h3 class="break-words text-sm font-semibold">
|
|
||||||
{{ user.name }}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground break-all text-xs"
|
|
||||||
>
|
|
||||||
{{ user.email }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid min-w-0 gap-2">
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs font-medium"
|
|
||||||
>
|
|
||||||
{{ t('projectAccess.effective') }}
|
|
||||||
</p>
|
|
||||||
<ProjectPermissionBadges
|
|
||||||
:permissions="
|
|
||||||
effectivePermissions[user.id] ?? []
|
|
||||||
"
|
|
||||||
:empty-label="t('projectAccess.noAccess')"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="text-muted-foreground grid gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="globalAdminUserIds.includes(user.id)"
|
|
||||||
>{{ t('projectAccess.globalSource') }}</span
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span>{{ t('projectAccess.direct') }}</span
|
|
||||||
><ProjectPermissionBadges
|
|
||||||
:permissions="
|
|
||||||
directPermissions[user.id] ?? []
|
|
||||||
"
|
|
||||||
:empty-label="t('projectAccess.none')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="team in inheritedTeams(user.id)"
|
|
||||||
:key="team.id"
|
|
||||||
class="flex min-w-0 flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<span class="break-words">{{
|
|
||||||
t('projectAccess.viaTeam', {
|
|
||||||
name: team.name,
|
|
||||||
})
|
|
||||||
}}</span
|
|
||||||
><ProjectPermissionBadges
|
|
||||||
:permissions="
|
|
||||||
teamPermissions[team.id] ?? []
|
|
||||||
"
|
|
||||||
:empty-label="t('projectAccess.none')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
:aria-label="
|
|
||||||
t('projectAccess.editFor', { name: user.name })
|
|
||||||
"
|
|
||||||
@click="
|
|
||||||
target = {
|
|
||||||
kind: 'user',
|
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
permissions:
|
|
||||||
directPermissions[user.id] ?? [],
|
|
||||||
globalAdmin: globalAdminUserIds.includes(
|
|
||||||
user.id,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>{{ t('projectAccess.editDirect') }}</Button
|
|
||||||
>
|
>
|
||||||
</li>
|
<thead
|
||||||
</ul>
|
class="bg-muted/40 text-muted-foreground text-left"
|
||||||
</section>
|
>
|
||||||
<section class="grid min-w-0 gap-4" aria-labelledby="teams-heading">
|
<tr>
|
||||||
<div class="grid gap-1">
|
<th class="px-4 py-3 font-medium">User</th>
|
||||||
<div class="flex items-center gap-2">
|
<th class="px-4 py-3 font-medium">Email</th>
|
||||||
<h2 id="teams-heading" class="text-lg font-semibold">
|
<th class="px-4 py-3 font-medium">
|
||||||
{{ t('projectAccess.teamPermissions') }}
|
Permissions
|
||||||
</h2>
|
</th>
|
||||||
<Badge variant="secondary">{{ teams.length }}</Badge>
|
<th
|
||||||
|
class="px-4 py-3 text-right font-medium"
|
||||||
|
>
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-border divide-y">
|
||||||
|
<tr
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
class="bg-background align-top"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="text-foreground px-4 py-4 font-medium"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted-foreground px-4 py-4">
|
||||||
|
{{ user.email }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
ProjectController.updateUserPermissions.form(
|
||||||
|
[project.id, user.id],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="flex flex-wrap gap-3"
|
||||||
|
v-slot="{ processing }"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
:key="`${user.id}-${option.value}`"
|
||||||
|
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="permissions[]"
|
||||||
|
:value="option.value"
|
||||||
|
:checked="
|
||||||
|
hasPermission(
|
||||||
|
directPermissions,
|
||||||
|
user.id,
|
||||||
|
option.value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="w-full sm:hidden" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="text-muted-foreground px-4 py-4 text-right"
|
||||||
|
>
|
||||||
|
Direct
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground text-sm">
|
</CardContent>
|
||||||
{{ t('projectAccess.teamHelp') }}
|
</Card>
|
||||||
</p>
|
|
||||||
</div>
|
<Card class="gap-0 overflow-hidden">
|
||||||
<FeedbackState
|
<CardHeader class="border-b">
|
||||||
v-if="visibleTeams.length === 0"
|
<CardTitle>Team permissions</CardTitle>
|
||||||
state="empty"
|
<CardDescription>
|
||||||
:title="
|
Team assignments are inherited by every current and
|
||||||
t(
|
future member of that team.
|
||||||
teams.length
|
</CardDescription>
|
||||||
? 'projectAccess.noTeamResults'
|
</CardHeader>
|
||||||
: 'projectAccess.noTeams',
|
|
||||||
)
|
<CardContent class="p-0">
|
||||||
"
|
<div
|
||||||
:description="
|
v-if="teams.length === 0"
|
||||||
teams.length
|
class="text-muted-foreground p-6 text-sm"
|
||||||
? undefined
|
|
||||||
: t('projectAccess.noTeamsHelp')
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<ul
|
|
||||||
v-else
|
|
||||||
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
|
||||||
:aria-label="t('projectAccess.teamPermissions')"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="team in visibleTeams"
|
|
||||||
:key="team.id"
|
|
||||||
class="bg-card grid min-w-0 gap-4 rounded-xl border p-5"
|
|
||||||
:data-team-id="team.id"
|
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
No teams exist yet. Create teams first to assign
|
||||||
<span
|
inherited permissions.
|
||||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
|
</div>
|
||||||
><Users class="size-5" aria-hidden="true"
|
|
||||||
/></span>
|
<div v-else class="overflow-x-auto">
|
||||||
<div class="min-w-0">
|
<table
|
||||||
<h3 class="break-words text-sm font-semibold">
|
class="divide-border min-w-full divide-y text-sm"
|
||||||
{{ team.name }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
{{
|
|
||||||
t('teams.memberCount', {
|
|
||||||
count: team.users.length,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ProjectPermissionBadges
|
|
||||||
:permissions="teamPermissions[team.id] ?? []"
|
|
||||||
:empty-label="t('projectAccess.noTeamAccess')"
|
|
||||||
/>
|
|
||||||
<p class="text-muted-foreground break-words text-xs">
|
|
||||||
{{
|
|
||||||
team.users
|
|
||||||
.map((user) => user.name)
|
|
||||||
.join(', ') || t('teams.noMembers')
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
:aria-label="
|
|
||||||
t('projectAccess.editFor', { name: team.name })
|
|
||||||
"
|
|
||||||
@click="
|
|
||||||
target = {
|
|
||||||
kind: 'team',
|
|
||||||
id: team.id,
|
|
||||||
name: team.name,
|
|
||||||
permissions: teamPermissions[team.id] ?? [],
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>{{ t('projectAccess.editTeam') }}</Button
|
|
||||||
>
|
>
|
||||||
</li>
|
<thead
|
||||||
</ul>
|
class="bg-muted/40 text-muted-foreground text-left"
|
||||||
</section>
|
>
|
||||||
<ProjectAccessEditorDialog
|
<tr>
|
||||||
:project-id="project.id"
|
<th class="px-4 py-3 font-medium">Team</th>
|
||||||
:target="target"
|
<th class="px-4 py-3 font-medium">
|
||||||
:permission-options="permissionOptions"
|
Members
|
||||||
@close="target = null"
|
</th>
|
||||||
/>
|
<th class="px-4 py-3 font-medium">
|
||||||
</main>
|
Permissions
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right font-medium"
|
||||||
|
>
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-border divide-y">
|
||||||
|
<tr
|
||||||
|
v-for="team in teams"
|
||||||
|
:key="team.id"
|
||||||
|
class="bg-background align-top"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="text-foreground px-4 py-4 font-medium"
|
||||||
|
>
|
||||||
|
{{ team.name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="member in team.users"
|
||||||
|
:key="member.id"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ member.name }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="team.users.length === 0"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
No members assigned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
ProjectController.updateTeamPermissions.form(
|
||||||
|
[project.id, team.id],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="flex flex-wrap gap-3"
|
||||||
|
v-slot="{ processing }"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
:key="`${team.id}-${option.value}`"
|
||||||
|
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="permissions[]"
|
||||||
|
:value="option.value"
|
||||||
|
:checked="
|
||||||
|
hasPermission(
|
||||||
|
teamPermissions,
|
||||||
|
team.id,
|
||||||
|
option.value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="w-full sm:hidden" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="text-muted-foreground px-4 py-4 text-right"
|
||||||
|
>
|
||||||
|
Inherited
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,204 +1,356 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
import { Plus, Search, Shield, Users } from 'lucide-vue-next';
|
import { Shield } from 'lucide-vue-next';
|
||||||
import { computed, ref } from 'vue';
|
import TeamController from '@/actions/App/Http/Controllers/TeamController';
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import TeamEditorDialog from '@/components/teams/TeamEditorDialog.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { index } from '@/routes/teams';
|
import { index as teamsIndex } from '@/routes/teams';
|
||||||
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
||||||
import type { TeamEditorTarget } from '@/types/teams';
|
|
||||||
|
|
||||||
const props = defineProps<TeamsIndexPageProps>();
|
const props = defineProps<TeamsIndexPageProps>();
|
||||||
const { t } = useLocalization();
|
|
||||||
const search = ref('');
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
const target = ref<TeamEditorTarget | null>(null);
|
{
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
title: 'Teams',
|
||||||
{ title: t('navigation.teams'), href: index() },
|
href: teamsIndex(),
|
||||||
]);
|
},
|
||||||
const visibleTeams = computed(() =>
|
];
|
||||||
props.teams.filter((team) =>
|
|
||||||
team.name
|
const hasPermission = (
|
||||||
.toLocaleLowerCase()
|
permissions: Record<string, string[]>,
|
||||||
.includes(search.value.trim().toLocaleLowerCase()),
|
actorId: number,
|
||||||
),
|
permission: string,
|
||||||
);
|
) => (permissions[actorId] ?? []).includes(permission);
|
||||||
|
|
||||||
|
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
|
||||||
|
props.teamPermissions[teamId] ?? {};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="t('navigation.teams')" />
|
<Head title="Teams" />
|
||||||
<main
|
|
||||||
class="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
>
|
<div
|
||||||
<header
|
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="grid min-w-0 gap-2">
|
<Heading
|
||||||
<p
|
title="Teams"
|
||||||
class="text-primary text-xs font-semibold uppercase tracking-wider"
|
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
|
||||||
>
|
/>
|
||||||
{{ t('teams.eyebrow') }}
|
|
||||||
</p>
|
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
||||||
<h1 class="text-headline-mobile sm:text-headline">
|
<Shield class="size-3.5" />
|
||||||
{{ t('navigation.teams') }}
|
{{ canCreateTeams ? 'Global admin' : 'Team-scoped access' }}
|
||||||
</h1>
|
</Badge>
|
||||||
<p class="text-muted-foreground max-w-2xl text-sm">
|
</div>
|
||||||
{{ t('teams.description') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
v-if="canCreateTeams"
|
|
||||||
type="button"
|
|
||||||
class="shrink-0"
|
|
||||||
@click="target = { kind: 'create' }"
|
|
||||||
><Plus class="size-4" aria-hidden="true" />{{
|
|
||||||
t('teams.create')
|
|
||||||
}}</Button
|
|
||||||
>
|
|
||||||
</header>
|
|
||||||
<FeedbackState
|
|
||||||
v-if="status"
|
|
||||||
state="success"
|
|
||||||
:title="t('teams.updated')"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="bg-card flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
|
v-if="status"
|
||||||
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
{{ status }}
|
||||||
<Users class="text-primary size-5" aria-hidden="true" />
|
</div>
|
||||||
<h2 class="font-semibold">{{ t('teams.yourTeams') }}</h2>
|
|
||||||
<Badge variant="secondary">{{ teams.length }}</Badge>
|
<div
|
||||||
</div>
|
class="grid gap-6"
|
||||||
<div class="relative sm:w-80">
|
:class="
|
||||||
<Label for="team-search" class="sr-only">{{
|
canCreateTeams
|
||||||
t('teams.search')
|
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
|
||||||
}}</Label
|
: 'xl:grid-cols-1'
|
||||||
><Search
|
"
|
||||||
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
>
|
||||||
aria-hidden="true"
|
<Card v-if="canCreateTeams">
|
||||||
/><Input
|
<CardHeader>
|
||||||
id="team-search"
|
<CardTitle>Create team</CardTitle>
|
||||||
v-model="search"
|
<CardDescription>
|
||||||
type="search"
|
Teams group users so project permissions can be
|
||||||
class="pl-9"
|
granted once and inherited by every member.
|
||||||
:placeholder="t('teams.search')"
|
</CardDescription>
|
||||||
/>
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Form
|
||||||
|
v-bind="TeamController.store.form()"
|
||||||
|
class="space-y-4"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Team name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="Platform Team"
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" :disabled="processing">
|
||||||
|
Create team
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<Card
|
||||||
|
v-for="team in teams"
|
||||||
|
:key="team.id"
|
||||||
|
class="gap-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<CardTitle>{{ team.name }}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{ team.users.length }} member{{
|
||||||
|
team.users.length === 1 ? '' : 's'
|
||||||
|
}}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="member in team.users"
|
||||||
|
:key="member.id"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ member.name }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-6 pt-6">
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
TeamController.updateMembers.form(team.id)
|
||||||
|
"
|
||||||
|
class="space-y-4"
|
||||||
|
v-slot="{ processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label :for="`team-members-${team.id}`">
|
||||||
|
Members
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
:id="`team-members-${team.id}`"
|
||||||
|
name="user_ids[]"
|
||||||
|
multiple
|
||||||
|
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
:selected="
|
||||||
|
team.user_ids.includes(user.id)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ user.name }} ({{ user.email }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Hold Cmd or Ctrl to select multiple
|
||||||
|
users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Save members
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div class="space-y-4 border-t pt-6">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-foreground font-medium">
|
||||||
|
Team permissions
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Direct user permissions on this team are
|
||||||
|
stored separately from team membership.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table
|
||||||
|
class="divide-border min-w-full divide-y text-sm"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-muted/40 text-muted-foreground text-left"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 font-medium"
|
||||||
|
>
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 font-medium"
|
||||||
|
>
|
||||||
|
Current permissions
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-right font-medium"
|
||||||
|
>
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-border divide-y">
|
||||||
|
<tr
|
||||||
|
v-for="user in users"
|
||||||
|
:key="`${team.id}-${user.id}`"
|
||||||
|
class="bg-background align-top"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div
|
||||||
|
class="text-foreground font-medium"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{{ user.email }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
v-show="
|
||||||
|
hasPermission(
|
||||||
|
permissionsForTeam(
|
||||||
|
team.id,
|
||||||
|
),
|
||||||
|
user.id,
|
||||||
|
option.value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:key="`${team.id}-${user.id}-${option.value}-badge`"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
(
|
||||||
|
permissionsForTeam(
|
||||||
|
team.id,
|
||||||
|
)[
|
||||||
|
user.id
|
||||||
|
] ?? []
|
||||||
|
).length === 0
|
||||||
|
"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
No direct team
|
||||||
|
permissions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4">
|
||||||
|
<div
|
||||||
|
class="flex justify-end"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
v-bind="
|
||||||
|
TeamController.updateUserPermissions.form(
|
||||||
|
[
|
||||||
|
team.id,
|
||||||
|
user.id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="flex flex-wrap justify-end gap-3"
|
||||||
|
v-slot="{
|
||||||
|
processing,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="option in permissionOptions"
|
||||||
|
:key="`${team.id}-${user.id}-${option.value}`"
|
||||||
|
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="permissions[]"
|
||||||
|
:value="
|
||||||
|
option.value
|
||||||
|
"
|
||||||
|
:checked="
|
||||||
|
hasPermission(
|
||||||
|
permissionsForTeam(
|
||||||
|
team.id,
|
||||||
|
),
|
||||||
|
user.id,
|
||||||
|
option.value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
option.label
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="w-full sm:hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
processing
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-if="teams.length === 0">
|
||||||
|
<CardContent class="text-muted-foreground p-6 text-sm">
|
||||||
|
No teams are available for your current permissions.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FeedbackState
|
</div>
|
||||||
v-if="teams.length === 0"
|
|
||||||
state="empty"
|
|
||||||
:title="t('teams.empty')"
|
|
||||||
:description="
|
|
||||||
t(
|
|
||||||
canCreateTeams
|
|
||||||
? 'teams.emptyCreate'
|
|
||||||
: 'teams.emptyDescription',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<FeedbackState
|
|
||||||
v-else-if="visibleTeams.length === 0"
|
|
||||||
state="empty"
|
|
||||||
:title="t('teams.noResults')"
|
|
||||||
:description="t('teams.noResultsDescription')"
|
|
||||||
><Button type="button" variant="outline" @click="search = ''">{{
|
|
||||||
t('teams.clearSearch')
|
|
||||||
}}</Button></FeedbackState
|
|
||||||
>
|
|
||||||
<ul
|
|
||||||
v-else
|
|
||||||
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
|
||||||
:aria-label="t('teams.yourTeams')"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="team in visibleTeams"
|
|
||||||
:key="team.id"
|
|
||||||
class="bg-card flex min-w-0 flex-col rounded-xl border"
|
|
||||||
:data-team-id="team.id"
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 items-start gap-3 p-5">
|
|
||||||
<span
|
|
||||||
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-lg"
|
|
||||||
><Users class="size-5" aria-hidden="true"
|
|
||||||
/></span>
|
|
||||||
<div class="grid min-w-0 gap-1">
|
|
||||||
<h3 class="break-words font-semibold">
|
|
||||||
{{ team.name }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{
|
|
||||||
t('teams.memberCount', {
|
|
||||||
count: team.users.length,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 px-5 pb-5">
|
|
||||||
<ul
|
|
||||||
v-if="team.users.length"
|
|
||||||
class="flex flex-wrap gap-2"
|
|
||||||
:aria-label="t('teams.members')"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="member in team.users"
|
|
||||||
:key="member.id"
|
|
||||||
class="bg-muted/60 flex min-w-0 max-w-full items-center gap-2 rounded-full py-1 pl-1 pr-3"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
:user="member"
|
|
||||||
class="size-6 shrink-0"
|
|
||||||
/><span class="min-w-0 break-words text-xs">{{
|
|
||||||
member.name
|
|
||||||
}}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p v-else class="text-muted-foreground text-sm">
|
|
||||||
{{ t('teams.noMembers') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="bg-muted/20 grid gap-2 border-t p-4 sm:grid-cols-2"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
:aria-label="
|
|
||||||
t('teams.manageMembersFor', { name: team.name })
|
|
||||||
"
|
|
||||||
@click="target = { kind: 'members', team }"
|
|
||||||
><Users class="size-4" aria-hidden="true" />{{
|
|
||||||
t('teams.manageMembers')
|
|
||||||
}}</Button
|
|
||||||
><Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
:aria-label="
|
|
||||||
t('teams.permissionsFor', { name: team.name })
|
|
||||||
"
|
|
||||||
@click="target = { kind: 'permissions', team }"
|
|
||||||
><Shield class="size-4" aria-hidden="true" />{{
|
|
||||||
t('teams.permissions')
|
|
||||||
}}</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</main>
|
|
||||||
<TeamEditorDialog
|
|
||||||
:target="target"
|
|
||||||
:users="users"
|
|
||||||
:permission-options="permissionOptions"
|
|
||||||
:team-permissions="teamPermissions"
|
|
||||||
@close="target = null"
|
|
||||||
/>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -117,8 +117,6 @@ export type PermissionOption = {
|
|||||||
|
|
||||||
export type ProjectAccessPageProps = {
|
export type ProjectAccessPageProps = {
|
||||||
directPermissions: Record<string, string[]>;
|
directPermissions: Record<string, string[]>;
|
||||||
effectivePermissions: Record<string, string[]>;
|
|
||||||
globalAdminUserIds: number[];
|
|
||||||
permissionOptions: PermissionOption[];
|
permissionOptions: PermissionOption[];
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
export const projectPermissionLabels = {
|
|
||||||
'project:read': 'projectAccess.read',
|
|
||||||
'project:write': 'projectAccess.write',
|
|
||||||
'project:admin': 'projectAccess.admin',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const projectPermissionDescriptions = {
|
|
||||||
'project:read': 'projectAccess.readHelp',
|
|
||||||
'project:write': 'projectAccess.writeHelp',
|
|
||||||
'project:admin': 'projectAccess.adminHelp',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type ProjectAccessTarget = {
|
|
||||||
kind: 'user' | 'team';
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
permissions: string[];
|
|
||||||
globalAdmin?: boolean;
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { PermissionOption } from '@/types';
|
|
||||||
import type { TeamModel } from '@/types/models/team-model';
|
|
||||||
import type { UserModel } from '@/types/models/user-model';
|
|
||||||
|
|
||||||
export type TeamEditorTarget =
|
|
||||||
| { kind: 'create' }
|
|
||||||
| { kind: 'members' | 'permissions'; team: TeamModel };
|
|
||||||
|
|
||||||
export type TeamEditorProps = {
|
|
||||||
target: TeamEditorTarget | null;
|
|
||||||
users: UserModel[];
|
|
||||||
permissionOptions: PermissionOption[];
|
|
||||||
teamPermissions: Record<string, Record<string, string[]>>;
|
|
||||||
};
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Projects;
|
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
|
||||||
use App\Enums\Permission;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Team;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class ProjectAccessTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_access_summary_matches_existing_permission_rules_and_excludes_other_scopes(): void
|
|
||||||
{
|
|
||||||
$admin = User::factory()->admin()->create();
|
|
||||||
$writer = User::factory()->create();
|
|
||||||
$member = User::factory()->create();
|
|
||||||
$outsider = User::factory()->create();
|
|
||||||
$project = Project::factory()->create();
|
|
||||||
$otherProject = Project::factory()->create();
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
$otherTeam = Team::factory()->create();
|
|
||||||
$team->users()->attach([$writer->id, $member->id]);
|
|
||||||
$otherTeam->users()->attach($writer);
|
|
||||||
$service = app(PermissionService::class);
|
|
||||||
$service->sync($writer, $project, [Permission::ProjectRead]);
|
|
||||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
|
||||||
$service->sync($otherTeam, $project, [Permission::ProjectRead]);
|
|
||||||
$service->sync($outsider, $otherProject, [Permission::ProjectAdmin]);
|
|
||||||
$service->sync($outsider, null, [Permission::ProjectCreate]);
|
|
||||||
$service->sync($outsider, $team, [Permission::TeamAdmin]);
|
|
||||||
|
|
||||||
$map = $service->projectAccessMap(User::all(), $project);
|
|
||||||
foreach (User::all() as $user) {
|
|
||||||
$expected = array_values(array_filter(Permission::projectAssignable(), fn (Permission $permission): bool => $service->allows($user, $permission, $project)));
|
|
||||||
$this->assertSame(Permission::values($expected), $map['effectivePermissions'][$user->id]);
|
|
||||||
}
|
|
||||||
$this->assertSame([], $map['effectivePermissions'][$outsider->id]);
|
|
||||||
$this->assertSame(['project:read', 'project:write'], $map['effectivePermissions'][$writer->id]);
|
|
||||||
$this->assertSame([$admin->id], $map['globalAdminUserIds']);
|
|
||||||
$this->actingAs($admin)->get(route('projects.access', $project))->assertOk()
|
|
||||||
->assertInertia(fn (Assert $page) => $page->component('projects/Access')
|
|
||||||
->where("effectivePermissions.{$writer->id}", ['project:read', 'project:write'])
|
|
||||||
->where("directPermissions.{$writer->id}", ['project:read'])
|
|
||||||
->where("teamPermissions.{$team->id}", ['project:write'])
|
|
||||||
->where('globalAdminUserIds', [$admin->id]));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_bulk_access_summary_uses_a_bounded_number_of_queries(): void
|
|
||||||
{
|
|
||||||
$project = Project::factory()->create();
|
|
||||||
User::factory()->create();
|
|
||||||
$service = app(PermissionService::class);
|
|
||||||
$countQueries = function () use ($service, $project): int {
|
|
||||||
$users = User::all();
|
|
||||||
DB::enableQueryLog();
|
|
||||||
DB::flushQueryLog();
|
|
||||||
$service->projectAccessMap($users, $project);
|
|
||||||
$count = count(DB::getQueryLog());
|
|
||||||
DB::disableQueryLog();
|
|
||||||
|
|
||||||
return $count;
|
|
||||||
};
|
|
||||||
$initialCount = $countQueries();
|
|
||||||
$users = User::factory()->count(10)->create();
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
$team->users()->attach($users);
|
|
||||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
|
||||||
$this->assertSame($initialCount, $countQueries());
|
|
||||||
$this->assertLessThanOrEqual(4, $initialCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_readers_writers_and_unrelated_admins_cannot_manage_project_access(): void
|
|
||||||
{
|
|
||||||
$project = Project::factory()->create();
|
|
||||||
$other = Project::factory()->create();
|
|
||||||
$member = User::factory()->create();
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
$service = app(PermissionService::class);
|
|
||||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
|
||||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
|
||||||
foreach ([[], [Permission::ProjectRead], [Permission::ProjectWrite]] as $permissions) {
|
|
||||||
$actor = User::factory()->create();
|
|
||||||
$service->sync($actor, $project, $permissions);
|
|
||||||
$service->sync($actor, $other, [Permission::ProjectAdmin]);
|
|
||||||
$this->actingAs($actor)->get(route('projects.access', $project))->assertForbidden();
|
|
||||||
foreach ([['users', $member], ['teams', $team]] as [$kind, $assignee]) {
|
|
||||||
$this->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => ['project:admin']])->assertForbidden();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->assertSame(['project:read'], $service->assignmentMap(User::class, $project)[$member->id]);
|
|
||||||
$this->assertSame(['project:write'], $service->assignmentMap(Team::class, $project)[$team->id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_invalid_assignments_preserve_both_user_and_team_permissions(): void
|
|
||||||
{
|
|
||||||
$admin = User::factory()->admin()->create();
|
|
||||||
$project = Project::factory()->create();
|
|
||||||
$service = app(PermissionService::class);
|
|
||||||
foreach ([['users', User::factory()->create()], ['teams', Team::factory()->create()]] as [$kind, $assignee]) {
|
|
||||||
$service->sync($assignee, $project, [Permission::ProjectRead]);
|
|
||||||
foreach ([['global:admin'], ['team:admin'], ['unknown'], ['project:read', 'project:read']] as $permissions) {
|
|
||||||
$this->actingAs($admin)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => $permissions])
|
|
||||||
->assertSessionHasErrors('permissions.0');
|
|
||||||
$this->assertSame(['project:read'], $service->assignmentMap($assignee::class, $project)[$assignee->id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_clearing_assignments_preserves_access_from_other_sources(): void
|
|
||||||
{
|
|
||||||
$admin = User::factory()->admin()->create();
|
|
||||||
$member = User::factory()->create();
|
|
||||||
$project = Project::factory()->create();
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
$team->users()->attach($member);
|
|
||||||
$service = app(PermissionService::class);
|
|
||||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
|
||||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
|
||||||
$this->actingAs($admin)->patch(route('projects.permissions.users.update', [$project, $member]), ['permissions' => []])
|
|
||||||
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
|
|
||||||
$this->assertTrue($service->allows($member, Permission::ProjectWrite, $project));
|
|
||||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
|
||||||
$this->patch(route('projects.permissions.teams.update', [$project, $team]), ['permissions' => []])
|
|
||||||
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
|
|
||||||
$this->assertTrue($service->allows($member, Permission::ProjectRead, $project));
|
|
||||||
$this->assertFalse($service->allows($member, Permission::ProjectWrite, $project));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_removing_own_admin_access_redirects_to_projects_for_direct_and_team_grants(): void
|
|
||||||
{
|
|
||||||
$service = app(PermissionService::class);
|
|
||||||
foreach (['users', 'teams'] as $kind) {
|
|
||||||
$actor = User::factory()->create();
|
|
||||||
$project = Project::factory()->create();
|
|
||||||
$assignee = $actor;
|
|
||||||
if ($kind === 'teams') {
|
|
||||||
$assignee = Team::factory()->create();
|
|
||||||
$assignee->users()->attach($actor);
|
|
||||||
}
|
|
||||||
$service->sync($assignee, $project, [Permission::ProjectAdmin]);
|
|
||||||
$this->actingAs($actor)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => []])
|
|
||||||
->assertRedirect(route('projects.index'))->assertSessionHasNoErrors();
|
|
||||||
$this->assertFalse($service->allows($actor, Permission::ProjectAdmin, $project));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Projects;
|
|
||||||
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class ProjectAccessUiTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_access_overview_renders_sources_empty_states_and_localization(): void
|
|
||||||
{
|
|
||||||
$process = new Process(['node', 'tests/Fixtures/project-access.mjs'], base_path());
|
|
||||||
$process->setTimeout(60);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
|
||||||
$this->assertStringContainsString('Project access presentation checks passed.', $process->getOutput());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -214,89 +214,4 @@ class TeamControllerTest extends TestCase
|
|||||||
'scope_type' => $team->getMorphClass(),
|
'scope_type' => $team->getMorphClass(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_team_managers_only_see_their_manageable_teams(): void
|
|
||||||
{
|
|
||||||
$manager = User::factory()->create();
|
|
||||||
$managed = Team::factory()->create();
|
|
||||||
$other = Team::factory()->create();
|
|
||||||
$other->users()->attach($manager);
|
|
||||||
$this->syncTeamPermissions($managed, $manager, ['team:manage']);
|
|
||||||
|
|
||||||
$this->actingAs($manager)->get(route('teams.index'))
|
|
||||||
->assertInertia(fn (Assert $page) => $page
|
|
||||||
->has('teams', 1)
|
|
||||||
->where('teams.0.id', $managed->id)
|
|
||||||
->where('canCreateTeams', false)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($manager)->patch(route('teams.members.update', $other), ['user_ids' => []])
|
|
||||||
->assertForbidden();
|
|
||||||
$this->actingAs($manager)->patch(route('teams.permissions.users.update', [$other, $manager]), ['permissions' => ['team:admin']])
|
|
||||||
->assertForbidden();
|
|
||||||
$this->assertDatabaseHas('team_user', ['team_id' => $other->id, 'user_id' => $manager->id]);
|
|
||||||
$this->assertDatabaseMissing('permission_assignments', ['scope_id' => $other->id, 'scope_type' => $other->getMorphClass()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_invalid_team_names_are_rejected(): void
|
|
||||||
{
|
|
||||||
$admin = User::factory()->admin()->create();
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
|
|
||||||
foreach (['', $team->name, str_repeat('a', 256)] as $name) {
|
|
||||||
$this->actingAs($admin)->from(route('teams.index'))->post(route('teams.store'), ['name' => $name])
|
|
||||||
->assertRedirect(route('teams.index'))->assertSessionHasErrors('name');
|
|
||||||
}
|
|
||||||
$this->assertDatabaseCount('teams', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_invalid_members_preserve_existing_membership(): void
|
|
||||||
{
|
|
||||||
$admin = User::factory()->admin()->create();
|
|
||||||
$member = User::factory()->create();
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
$team->users()->attach($member);
|
|
||||||
|
|
||||||
foreach ([[$member->id, $member->id], [999999], ['invalid']] as $ids) {
|
|
||||||
$this->actingAs($admin)->patch(route('teams.members.update', $team), ['user_ids' => $ids])
|
|
||||||
->assertSessionHasErrors('user_ids.0');
|
|
||||||
$this->assertSame([$member->id], $team->users()->pluck('users.id')->all());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_membership_and_direct_permissions_can_be_cleared_independently(): void
|
|
||||||
{
|
|
||||||
$manager = User::factory()->create();
|
|
||||||
$member = User::factory()->create();
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
$team->users()->attach($member);
|
|
||||||
$this->syncTeamPermissions($team, $manager, ['team:manage']);
|
|
||||||
$this->syncTeamPermissions($team, $member, ['team:invite']);
|
|
||||||
|
|
||||||
$this->actingAs($manager)->patch(route('teams.members.update', $team), ['user_ids' => []])
|
|
||||||
->assertRedirect(route('teams.index'))->assertSessionHasNoErrors();
|
|
||||||
$this->assertDatabaseMissing('team_user', ['team_id' => $team->id]);
|
|
||||||
$this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']);
|
|
||||||
|
|
||||||
$team->users()->attach($member);
|
|
||||||
$this->actingAs($manager)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => []])
|
|
||||||
->assertRedirect(route('teams.index'))->assertSessionHasNoErrors();
|
|
||||||
$this->assertDatabaseHas('team_user', ['team_id' => $team->id, 'user_id' => $member->id]);
|
|
||||||
$this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'scope_type' => $team->getMorphClass()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_invalid_permissions_preserve_existing_assignments(): void
|
|
||||||
{
|
|
||||||
$admin = User::factory()->admin()->create();
|
|
||||||
$member = User::factory()->create();
|
|
||||||
$team = Team::factory()->create();
|
|
||||||
$this->syncTeamPermissions($team, $member, ['team:invite']);
|
|
||||||
|
|
||||||
foreach ([['global:admin'], ['team:manage', 'team:manage'], ['unknown']] as $permissions) {
|
|
||||||
$this->actingAs($admin)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => $permissions])
|
|
||||||
->assertSessionHasErrors('permissions.0');
|
|
||||||
$this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']);
|
|
||||||
$this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'permission' => 'global:admin']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Teams;
|
|
||||||
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TeamPresentationTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_team_overview_respects_permissions_empty_states_and_localization(): void
|
|
||||||
{
|
|
||||||
$process = new Process(['node', 'tests/Fixtures/teams.mjs'], base_path());
|
|
||||||
$process->setTimeout(60);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
|
||||||
$this->assertStringContainsString('Teams presentation checks passed.', $process->getOutput());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -68,16 +68,14 @@ try {
|
|||||||
]) {
|
]) {
|
||||||
await open(path);
|
await open(path);
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('radio', {
|
||||||
name: theme === 'light' ? 'Light' : 'Dark',
|
name: theme === 'light' ? /^Light / : /^Dark /,
|
||||||
exact: true,
|
|
||||||
})
|
})
|
||||||
.click();
|
.press('Space');
|
||||||
await page
|
await page
|
||||||
.getByRole('button', {
|
.getByRole('radio', {
|
||||||
name: theme === 'light' ? 'Light' : 'Dark',
|
name: theme === 'light' ? /^Light / : /^Dark /,
|
||||||
exact: true,
|
checked: true,
|
||||||
pressed: true,
|
|
||||||
})
|
})
|
||||||
.waitFor();
|
.waitFor();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -118,7 +116,7 @@ try {
|
|||||||
}
|
}
|
||||||
await page.setViewportSize({ width: 390, height: 480 });
|
await page.setViewportSize({ width: 390, height: 480 });
|
||||||
await open('/login');
|
await open('/login');
|
||||||
await page.getByRole('button', { name: 'System', exact: true }).click();
|
await page.getByRole('radio', { name: /^System / }).press('Space');
|
||||||
await page.emulateMedia({ colorScheme: 'light' });
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.documentElement.classList.contains('dark'),
|
() => !document.documentElement.classList.contains('dark'),
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import process from 'node:process';
|
|
||||||
const { chromium } = await import(
|
|
||||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
|
||||||
);
|
|
||||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
|
||||||
const context = await browser.newContext({ reducedMotion: 'reduce' });
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(10000);
|
|
||||||
const errors = [];
|
|
||||||
page.on('pageerror', (error) => errors.push(error.message));
|
|
||||||
const base = 'http://127.0.0.1:4198';
|
|
||||||
const result = (value) =>
|
|
||||||
context.addCookies([{ name: 'access_result', value, url: base }]);
|
|
||||||
const visit = async (query = '') => {
|
|
||||||
await result('success');
|
|
||||||
await page.goto(base + '/projects/1/access?reset' + query);
|
|
||||||
await page
|
|
||||||
.getByRole('heading', { name: 'Project access', exact: true })
|
|
||||||
.waitFor();
|
|
||||||
};
|
|
||||||
const button = (name) => page.getByRole('button', { name, exact: true });
|
|
||||||
const dialog = () => page.getByRole('dialog');
|
|
||||||
const checkbox = (name) =>
|
|
||||||
dialog().getByRole('checkbox', { name, exact: true });
|
|
||||||
const state = (value) =>
|
|
||||||
dialog()
|
|
||||||
.locator('[data-form-state="' + value + '"]')
|
|
||||||
.waitFor({ state: 'visible' });
|
|
||||||
const noOverflow = async () =>
|
|
||||||
assert.equal(
|
|
||||||
await page.evaluate(
|
|
||||||
() => document.documentElement.scrollWidth <= innerWidth,
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const save = async () => {
|
|
||||||
const request = page.waitForRequest((r) => r.method() === 'PATCH');
|
|
||||||
await dialog()
|
|
||||||
.getByRole('button', { name: 'Save permissions', exact: true })
|
|
||||||
.click();
|
|
||||||
return await request;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
for (const width of [1440, 390, 320]) {
|
|
||||||
await page.setViewportSize({ width, height: 1000 });
|
|
||||||
for (const theme of ['light', 'dark']) {
|
|
||||||
await page.emulateMedia({ colorScheme: theme });
|
|
||||||
await visit();
|
|
||||||
await noOverflow();
|
|
||||||
const trigger = button('Edit access for Sam Taylor');
|
|
||||||
await trigger.focus();
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
await dialog().waitFor();
|
|
||||||
await noOverflow();
|
|
||||||
assert.equal(await checkbox('Read').isChecked(), true);
|
|
||||||
assert.equal(
|
|
||||||
await checkbox('Write').isChecked(),
|
|
||||||
false,
|
|
||||||
'Inherited write must not be editable as a direct grant',
|
|
||||||
);
|
|
||||||
await checkbox('Write').focus();
|
|
||||||
await page.keyboard.press('Space');
|
|
||||||
assert.equal(await checkbox('Write').isChecked(), true);
|
|
||||||
await page.keyboard.press('Space');
|
|
||||||
await page.keyboard.press('Tab');
|
|
||||||
assert.equal(
|
|
||||||
await dialog().evaluate((el) =>
|
|
||||||
el.contains(document.activeElement),
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await page.screenshot({
|
|
||||||
path:
|
|
||||||
'/private/tmp/kan48-dialog-' + width + '-' + theme + '.png',
|
|
||||||
});
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
assert.equal(
|
|
||||||
await trigger.evaluate((el) => el === document.activeElement),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await page.screenshot({
|
|
||||||
path: '/private/tmp/kan48-' + width + '-' + theme + '.png',
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await page.locator('#access-search').fill('SAM@EXAMPLE');
|
|
||||||
assert.equal(await page.locator('[data-user-id]').count(), 1);
|
|
||||||
await page.getByText('No matching teams', { exact: true }).waitFor();
|
|
||||||
await page.locator('#access-search').fill('unmatched');
|
|
||||||
await page.getByText('No matching people', { exact: true }).waitFor();
|
|
||||||
await visit('&empty&noUsers');
|
|
||||||
await page.getByText('No teams yet', { exact: true }).waitFor();
|
|
||||||
await page.getByText('No people available', { exact: true }).waitFor();
|
|
||||||
await visit();
|
|
||||||
await button('Edit access for Alex Morgan').click();
|
|
||||||
await dialog()
|
|
||||||
.getByText('This person is a global administrator.', { exact: false })
|
|
||||||
.waitFor();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await button('Edit access for Sam Taylor').click();
|
|
||||||
await checkbox('Read').uncheck();
|
|
||||||
const request = await save();
|
|
||||||
assert.equal(
|
|
||||||
new URL(request.url()).pathname,
|
|
||||||
'/projects/1/permissions/users/2',
|
|
||||||
);
|
|
||||||
assert.deepEqual(request.postDataJSON(), { permissions: [] });
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
await page
|
|
||||||
.locator('[data-user-id="2"]')
|
|
||||||
.getByText('Write', { exact: true })
|
|
||||||
.first()
|
|
||||||
.waitFor();
|
|
||||||
await button('Edit access for Sam Taylor').click();
|
|
||||||
assert.equal(await checkbox('Read').isChecked(), false);
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await button('Edit access for Platform team').click();
|
|
||||||
await checkbox('Write').uncheck();
|
|
||||||
await save();
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
await page
|
|
||||||
.locator('[data-user-id="2"]')
|
|
||||||
.getByText('No project access', { exact: true })
|
|
||||||
.waitFor();
|
|
||||||
|
|
||||||
await button('Edit access for Sam Taylor').click();
|
|
||||||
await checkbox('Administrator').check();
|
|
||||||
await result('validation');
|
|
||||||
await save();
|
|
||||||
await state('validation');
|
|
||||||
await dialog()
|
|
||||||
.getByText('This permission is unavailable.', { exact: true })
|
|
||||||
.waitFor();
|
|
||||||
assert.equal(await checkbox('Administrator').isChecked(), true);
|
|
||||||
assert.equal(
|
|
||||||
await dialog().locator('fieldset').getAttribute('aria-invalid'),
|
|
||||||
'true',
|
|
||||||
);
|
|
||||||
await result('server');
|
|
||||||
await save();
|
|
||||||
await state('server');
|
|
||||||
await result('success');
|
|
||||||
await page.route('**/permissions/users/2', (route) =>
|
|
||||||
route.abort('failed'),
|
|
||||||
);
|
|
||||||
await save();
|
|
||||||
await state('network');
|
|
||||||
await page.unroute('**/permissions/users/2');
|
|
||||||
await dialog()
|
|
||||||
.getByRole('button', { name: 'Try again', exact: true })
|
|
||||||
.click();
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
await page
|
|
||||||
.locator('[data-user-id="2"]')
|
|
||||||
.getByText('Administrator', { exact: true })
|
|
||||||
.first()
|
|
||||||
.waitFor();
|
|
||||||
await button('Edit access for Sam Taylor').click();
|
|
||||||
assert.equal(await checkbox('Administrator').isChecked(), true);
|
|
||||||
await result('forbidden');
|
|
||||||
await save();
|
|
||||||
await state('server');
|
|
||||||
await dialog()
|
|
||||||
.getByText('Project access can no longer be changed', { exact: true })
|
|
||||||
.waitFor();
|
|
||||||
assert.equal(
|
|
||||||
await dialog().getByRole('button', { name: 'Try again' }).count(),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await visit();
|
|
||||||
await button('Edit access for Sam Taylor').click();
|
|
||||||
await checkbox('Write').check();
|
|
||||||
page.once('dialog', (d) => d.dismiss());
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
assert.equal(await dialog().isVisible(), true);
|
|
||||||
page.once('dialog', (d) => d.accept());
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
assert.deepEqual(errors, []);
|
|
||||||
console.log(
|
|
||||||
'Project access browser checks passed: direct/team updates and clearing, effective access refresh, errors/retry, unsaved changes, keyboard, 320/390/1440px, both themes.',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { createApp, h } from 'vue';
|
|
||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
|
||||||
import '../../resources/css/app.css';
|
|
||||||
import Access from '../../resources/js/pages/projects/Access.vue';
|
|
||||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
|
||||||
import { createLocalization } from '../../resources/js/lib/localization';
|
|
||||||
createInertiaApp({
|
|
||||||
resolve: () => Access,
|
|
||||||
setup: ({ el, App, props, plugin }) =>
|
|
||||||
createApp({ render: () => h(App, props) })
|
|
||||||
.use(plugin)
|
|
||||||
.use(createLocalization())
|
|
||||||
.use(createAppearance())
|
|
||||||
.mount(el),
|
|
||||||
});
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import process from 'node:process';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import vue from '@vitejs/plugin-vue';
|
|
||||||
import tailwind from '@tailwindcss/vite';
|
|
||||||
import { createServer } from 'vite';
|
|
||||||
import { createSSRApp, h } from 'vue';
|
|
||||||
import { renderToString } from 'vue/server-renderer';
|
|
||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
|
||||||
const serve = process.argv.includes('--serve');
|
|
||||||
const server = await createServer({
|
|
||||||
configFile: false,
|
|
||||||
cacheDir: 'node_modules/.vite/project-access-fixture',
|
|
||||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: serve
|
|
||||||
? { host: '127.0.0.1', port: 4198, strictPort: true, hmr: false }
|
|
||||||
: { middlewareMode: true, hmr: false, watch: null },
|
|
||||||
appType: 'custom',
|
|
||||||
});
|
|
||||||
const users = [
|
|
||||||
{ id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null },
|
|
||||||
{ id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null },
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Jordan Longname With Several Names',
|
|
||||||
email: 'jordan.long.email.address@example.test',
|
|
||||||
avatar: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
function initial() {
|
|
||||||
return {
|
|
||||||
project: { id: 1, name: 'Core platform', key: 'CORE' },
|
|
||||||
users,
|
|
||||||
status: null,
|
|
||||||
globalAdminUserIds: [1],
|
|
||||||
directPermissions: { 2: ['project:read'] },
|
|
||||||
teamPermissions: { 1: ['project:write'] },
|
|
||||||
effectivePermissions: {
|
|
||||||
1: ['project:read', 'project:write', 'project:admin'],
|
|
||||||
2: ['project:read', 'project:write'],
|
|
||||||
3: [],
|
|
||||||
},
|
|
||||||
teams: [
|
|
||||||
{ id: 1, name: 'Platform team', user_ids: [2], users: [users[1]] },
|
|
||||||
{ id: 2, name: 'Delivery team', user_ids: [], users: [] },
|
|
||||||
],
|
|
||||||
permissionOptions: ['read', 'write', 'admin'].map((name) => ({
|
|
||||||
value: 'project:' + name,
|
|
||||||
label: name,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const makePage = (extra = {}) => ({
|
|
||||||
component: 'Access',
|
|
||||||
url: '/projects/1/access',
|
|
||||||
version: null,
|
|
||||||
clearHistory: false,
|
|
||||||
encryptHistory: false,
|
|
||||||
props: {
|
|
||||||
name: 'KanbanGit',
|
|
||||||
errors: {},
|
|
||||||
sidebarOpen: true,
|
|
||||||
appearance: 'system',
|
|
||||||
auth: {
|
|
||||||
user: users[0],
|
|
||||||
permissions: {
|
|
||||||
globalAdmin: false,
|
|
||||||
projectCreate: false,
|
|
||||||
canManageTeams: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
navigation: { project: null },
|
|
||||||
...initial(),
|
|
||||||
...extra,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (serve) {
|
|
||||||
let data = initial();
|
|
||||||
server.middlewares.use(async (request, response, next) => {
|
|
||||||
if (!request.url.startsWith('/projects/1/')) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
const url = new URL(request.url, 'http://localhost');
|
|
||||||
if (url.searchParams.has('reset')) {
|
|
||||||
data = initial();
|
|
||||||
}
|
|
||||||
if (url.searchParams.has('noUsers')) {
|
|
||||||
data.users = [];
|
|
||||||
}
|
|
||||||
if (url.searchParams.has('empty')) {
|
|
||||||
data.teams = [];
|
|
||||||
}
|
|
||||||
let errors = {};
|
|
||||||
if (request.method !== 'GET') {
|
|
||||||
const result =
|
|
||||||
request.headers.cookie?.match(/access_result=(\w+)/)?.[1] ??
|
|
||||||
'success';
|
|
||||||
if (['server', 'forbidden'].includes(result)) {
|
|
||||||
response.statusCode = result === 'forbidden' ? 403 : 503;
|
|
||||||
response.end('Simulated failure');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let body = '';
|
|
||||||
for await (const chunk of request) {
|
|
||||||
body += chunk;
|
|
||||||
}
|
|
||||||
const submitted = JSON.parse(body || '{}');
|
|
||||||
if (result === 'validation') {
|
|
||||||
errors = { 'permissions.0': 'This permission is unavailable.' };
|
|
||||||
} else {
|
|
||||||
const parts = url.pathname.split('/');
|
|
||||||
const kind = parts[4];
|
|
||||||
const id = parts[5];
|
|
||||||
data[
|
|
||||||
kind === 'users' ? 'directPermissions' : 'teamPermissions'
|
|
||||||
][id] = submitted.permissions;
|
|
||||||
for (const user of users) {
|
|
||||||
const grants = [
|
|
||||||
...(data.directPermissions[user.id] ?? []),
|
|
||||||
...data.teams
|
|
||||||
.filter((team) => team.user_ids.includes(user.id))
|
|
||||||
.flatMap(
|
|
||||||
(team) => data.teamPermissions[team.id] ?? [],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
data.effectivePermissions[user.id] =
|
|
||||||
data.globalAdminUserIds.includes(user.id) ||
|
|
||||||
grants.includes('project:admin')
|
|
||||||
? ['project:read', 'project:write', 'project:admin']
|
|
||||||
: grants.includes('project:write')
|
|
||||||
? ['project:read', 'project:write']
|
|
||||||
: grants.includes('project:read')
|
|
||||||
? ['project:read']
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(errors).length === 0) {
|
|
||||||
data.status = 'Saved';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const page = makePage({ ...data, errors });
|
|
||||||
response.setHeader('Vary', 'X-Inertia');
|
|
||||||
if (request.headers['x-inertia']) {
|
|
||||||
response.setHeader('X-Inertia', 'true');
|
|
||||||
response.setHeader('Content-Type', 'application/json');
|
|
||||||
response.end(JSON.stringify(page));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const escaped = JSON.stringify(page)
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll('<', '<');
|
|
||||||
response.setHeader('Content-Type', 'text/html');
|
|
||||||
response.end(
|
|
||||||
await server.transformIndexHtml(
|
|
||||||
request.url,
|
|
||||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
|
||||||
escaped +
|
|
||||||
'"></div><script type="module" src="/tests/Fixtures/project-access-preview.mjs"></script></body></html>',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await server.listen();
|
|
||||||
console.log('Project access fixture ready.');
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const { createLocalization } = await server.ssrLoadModule(
|
|
||||||
'/resources/js/lib/localization.ts',
|
|
||||||
);
|
|
||||||
const { createAppearance } = await server.ssrLoadModule(
|
|
||||||
'/resources/js/composables/useAppearance.ts',
|
|
||||||
);
|
|
||||||
const { en } = await server.ssrLoadModule(
|
|
||||||
'/resources/js/locales/en.ts',
|
|
||||||
);
|
|
||||||
const component = (
|
|
||||||
await server.ssrLoadModule(
|
|
||||||
'/resources/js/pages/projects/Access.vue',
|
|
||||||
)
|
|
||||||
).default;
|
|
||||||
const render = async (extra = {}, locale = createLocalization()) =>
|
|
||||||
(
|
|
||||||
await createInertiaApp({
|
|
||||||
page: makePage(extra),
|
|
||||||
resolve: () => component,
|
|
||||||
render: renderToString,
|
|
||||||
setup: ({ App, props, plugin }) =>
|
|
||||||
createSSRApp({ render: () => h(App, props) })
|
|
||||||
.use(plugin)
|
|
||||||
.use(locale)
|
|
||||||
.use(createAppearance()),
|
|
||||||
})
|
|
||||||
).body;
|
|
||||||
const html = await render();
|
|
||||||
assert.match(html, /People and effective access/);
|
|
||||||
assert.match(html, /Global administrator · all project permissions/);
|
|
||||||
assert.match(html, /Via Platform team:/);
|
|
||||||
assert.match(html, /No project access/);
|
|
||||||
assert.match(html, /Edit access for Sam Taylor/);
|
|
||||||
assert.match(html, /No project permissions assigned/);
|
|
||||||
assert.doesNotMatch(html, /<table|type="checkbox"/);
|
|
||||||
assert.doesNotMatch(html, /href="[^"]*settings\/general/);
|
|
||||||
assert.match(await render({ teams: [] }), /No teams yet/);
|
|
||||||
assert.match(await render({ users: [] }), /No people available/);
|
|
||||||
const translated = await render(
|
|
||||||
{},
|
|
||||||
createLocalization(
|
|
||||||
{ locale: 'de' },
|
|
||||||
{
|
|
||||||
de: {
|
|
||||||
...en,
|
|
||||||
'projectAccess.title': 'Projektzugriff',
|
|
||||||
'projectAccess.read': 'Lesen',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
assert.match(translated, /Projektzugriff/);
|
|
||||||
assert.match(translated, /Lesen/);
|
|
||||||
console.log('Project access presentation checks passed.');
|
|
||||||
} finally {
|
|
||||||
await server.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import process from 'node:process';
|
|
||||||
const { chromium } = await import(
|
|
||||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
|
||||||
);
|
|
||||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
|
||||||
const context = await browser.newContext({ reducedMotion: 'reduce' });
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(10000);
|
|
||||||
const errors = [];
|
|
||||||
page.on('pageerror', (error) => errors.push(error.message));
|
|
||||||
const base = 'http://127.0.0.1:4197';
|
|
||||||
const result = (value) =>
|
|
||||||
context.addCookies([{ name: 'teams_result', value, url: base }]);
|
|
||||||
const visit = async (query = '') => {
|
|
||||||
await result('success');
|
|
||||||
await page.goto(base + '/teams?reset' + query);
|
|
||||||
await page.getByRole('heading', { name: 'Teams', exact: true }).waitFor();
|
|
||||||
};
|
|
||||||
const button = (name) => page.getByRole('button', { name, exact: true });
|
|
||||||
const dialog = () => page.getByRole('dialog');
|
|
||||||
const state = (value) =>
|
|
||||||
dialog()
|
|
||||||
.locator('[data-form-state="' + value + '"]')
|
|
||||||
.waitFor({ state: 'visible' });
|
|
||||||
const noOverflow = async () =>
|
|
||||||
assert.equal(
|
|
||||||
await page.evaluate(
|
|
||||||
() => document.documentElement.scrollWidth <= innerWidth,
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const save = async (label) => {
|
|
||||||
const request = page.waitForRequest((r) =>
|
|
||||||
['POST', 'PATCH'].includes(r.method()),
|
|
||||||
);
|
|
||||||
await dialog().getByRole('button', { name: label, exact: true }).click();
|
|
||||||
return (await request).postDataJSON();
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
for (const width of [1440, 390, 320]) {
|
|
||||||
await page.setViewportSize({ width, height: 900 });
|
|
||||||
for (const theme of ['light', 'dark']) {
|
|
||||||
await page.emulateMedia({ colorScheme: theme });
|
|
||||||
await visit();
|
|
||||||
await noOverflow();
|
|
||||||
await page.screenshot({
|
|
||||||
path: '/private/tmp/kan47-' + width + '-' + theme + '.png',
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
const trigger = button('Manage members of Platform team');
|
|
||||||
await trigger.focus();
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
await dialog().waitFor();
|
|
||||||
await page.keyboard.press('Tab');
|
|
||||||
assert.equal(
|
|
||||||
await dialog().evaluate((el) =>
|
|
||||||
el.contains(document.activeElement),
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await noOverflow();
|
|
||||||
await page.screenshot({
|
|
||||||
path:
|
|
||||||
'/private/tmp/kan47-members-' +
|
|
||||||
width +
|
|
||||||
'-' +
|
|
||||||
theme +
|
|
||||||
'.png',
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
assert.equal(
|
|
||||||
await trigger.evaluate((el) => document.activeElement === el),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await visit('&manager');
|
|
||||||
assert.equal(await button('Create team').count(), 0);
|
|
||||||
await visit('&empty');
|
|
||||||
await page.getByText('No teams to manage', { exact: true }).waitFor();
|
|
||||||
await visit();
|
|
||||||
await page.getByLabel('Search teams', { exact: true }).fill('missing');
|
|
||||||
await page.getByText('No matching teams', { exact: true }).waitFor();
|
|
||||||
await button('Clear search').click();
|
|
||||||
await button('Create team').click();
|
|
||||||
await page.getByLabel('Team name', { exact: true }).fill('New team');
|
|
||||||
await result('validation');
|
|
||||||
assert.deepEqual(await save('Create team'), { name: 'New team' });
|
|
||||||
await state('validation');
|
|
||||||
assert.equal(
|
|
||||||
await page.getByLabel('Team name', { exact: true }).inputValue(),
|
|
||||||
'New team',
|
|
||||||
);
|
|
||||||
await page
|
|
||||||
.getByText('This team name is already taken.', { exact: true })
|
|
||||||
.waitFor();
|
|
||||||
await result('success');
|
|
||||||
await save('Create team');
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
await page
|
|
||||||
.getByRole('heading', { name: 'New team', exact: true })
|
|
||||||
.waitFor();
|
|
||||||
|
|
||||||
await button('Manage members of Platform team').click();
|
|
||||||
const person = (name) =>
|
|
||||||
dialog().getByRole('checkbox', { name: new RegExp(name) });
|
|
||||||
await page.getByLabel('Find people').fill('sam@');
|
|
||||||
await person('Sam Taylor').check();
|
|
||||||
await page.getByLabel('Find people').fill('');
|
|
||||||
assert.equal(await person('Alex Morgan').isChecked(), true);
|
|
||||||
let dismissed = false;
|
|
||||||
page.once('dialog', async (d) => {
|
|
||||||
dismissed = true;
|
|
||||||
await d.dismiss();
|
|
||||||
});
|
|
||||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
|
||||||
assert.equal(dismissed, true);
|
|
||||||
assert.equal(await dialog().isVisible(), true);
|
|
||||||
await result('validation');
|
|
||||||
assert.deepEqual(await save('Save members'), { user_ids: [1, 2] });
|
|
||||||
await state('validation');
|
|
||||||
await page
|
|
||||||
.getByText('This user is unavailable.', { exact: true })
|
|
||||||
.waitFor();
|
|
||||||
assert.equal(await person('Sam Taylor').isChecked(), true);
|
|
||||||
await page.route('**/teams/1/members', (route) => route.abort('failed'));
|
|
||||||
await save('Save members');
|
|
||||||
await state('network');
|
|
||||||
await page.unroute('**/teams/1/members');
|
|
||||||
await result('server');
|
|
||||||
await save('Save members');
|
|
||||||
await state('server');
|
|
||||||
await result('success');
|
|
||||||
await dialog()
|
|
||||||
.getByRole('button', { name: 'Try again', exact: true })
|
|
||||||
.click();
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
await button('Manage members of Platform team').click();
|
|
||||||
await person('Alex Morgan').uncheck();
|
|
||||||
await person('Sam Taylor').uncheck();
|
|
||||||
assert.deepEqual(await save('Save members'), { user_ids: [] });
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
|
|
||||||
await button('Manage permissions for Platform team').click();
|
|
||||||
assert.equal(
|
|
||||||
await dialog()
|
|
||||||
.getByRole('button', { name: 'Save permissions', exact: true })
|
|
||||||
.isDisabled(),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await page.getByLabel('User', { exact: true }).selectOption('2');
|
|
||||||
assert.equal(await person('Team invite').isChecked(), true);
|
|
||||||
await person('Team manage').check();
|
|
||||||
page.once('dialog', (d) => d.dismiss());
|
|
||||||
await page.getByLabel('User', { exact: true }).selectOption('3');
|
|
||||||
assert.equal(
|
|
||||||
await page.getByLabel('User', { exact: true }).inputValue(),
|
|
||||||
'2',
|
|
||||||
);
|
|
||||||
await result('validation');
|
|
||||||
assert.deepEqual(await save('Save permissions'), {
|
|
||||||
permissions: ['team:invite', 'team:manage'],
|
|
||||||
});
|
|
||||||
await state('validation');
|
|
||||||
await page
|
|
||||||
.getByText('This permission is unavailable.', { exact: true })
|
|
||||||
.waitFor();
|
|
||||||
await result('forbidden');
|
|
||||||
await save('Save permissions');
|
|
||||||
await state('server');
|
|
||||||
await page
|
|
||||||
.getByText('You no longer have permission', { exact: true })
|
|
||||||
.waitFor();
|
|
||||||
assert.equal(
|
|
||||||
await dialog()
|
|
||||||
.getByRole('button', { name: 'Try again', exact: true })
|
|
||||||
.count(),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
assert.equal(await person('Team manage').isChecked(), true);
|
|
||||||
await result('success');
|
|
||||||
await save('Save permissions');
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
await button('Manage permissions for Platform team').click();
|
|
||||||
await page.getByLabel('User', { exact: true }).selectOption('2');
|
|
||||||
assert.equal(await person('Team manage').isChecked(), true);
|
|
||||||
await person('Team invite').uncheck();
|
|
||||||
await person('Team manage').uncheck();
|
|
||||||
assert.deepEqual(await save('Save permissions'), { permissions: [] });
|
|
||||||
await dialog().waitFor({ state: 'hidden' });
|
|
||||||
assert.deepEqual(errors, []);
|
|
||||||
console.log(
|
|
||||||
'Teams browser checks passed: creation, membership, permissions, empty selections, validation, network/server/403, draft protection, focus, keyboard, themes and mobile.',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { createApp, h } from 'vue';
|
|
||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
|
||||||
import '../../resources/css/app.css';
|
|
||||||
import Teams from '../../resources/js/pages/teams/Index.vue';
|
|
||||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
|
||||||
import { createLocalization } from '../../resources/js/lib/localization';
|
|
||||||
createInertiaApp({
|
|
||||||
resolve: () => Teams,
|
|
||||||
setup: ({ el, App, props, plugin }) =>
|
|
||||||
createApp({ render: () => h(App, props) })
|
|
||||||
.use(plugin)
|
|
||||||
.use(createLocalization())
|
|
||||||
.use(createAppearance())
|
|
||||||
.mount(el),
|
|
||||||
});
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import process from 'node:process';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import vue from '@vitejs/plugin-vue';
|
|
||||||
import tailwind from '@tailwindcss/vite';
|
|
||||||
import { createServer } from 'vite';
|
|
||||||
import { createSSRApp, h } from 'vue';
|
|
||||||
import { renderToString } from 'vue/server-renderer';
|
|
||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
|
||||||
const serve = process.argv.includes('--serve');
|
|
||||||
const server = await createServer({
|
|
||||||
configFile: false,
|
|
||||||
cacheDir: 'node_modules/.vite/teams-fixture',
|
|
||||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: serve
|
|
||||||
? { host: '127.0.0.1', port: 4197, strictPort: true, hmr: false }
|
|
||||||
: { middlewareMode: true, hmr: false, watch: null },
|
|
||||||
appType: 'custom',
|
|
||||||
});
|
|
||||||
const users = [
|
|
||||||
{ id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null },
|
|
||||||
{ id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null },
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Jordan Longname With Several Names',
|
|
||||||
email: 'jordan.long.email.address@example.test',
|
|
||||||
avatar: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
function initial() {
|
|
||||||
return {
|
|
||||||
canCreateTeams: true,
|
|
||||||
users,
|
|
||||||
status: null,
|
|
||||||
teams: [
|
|
||||||
{ id: 1, name: 'Platform team', user_ids: [1], users: [users[0]] },
|
|
||||||
{ id: 2, name: 'Delivery team', user_ids: [], users: [] },
|
|
||||||
],
|
|
||||||
teamPermissions: { 1: { 2: ['team:invite'] } },
|
|
||||||
permissionOptions: ['invite', 'manage', 'admin'].map((name) => ({
|
|
||||||
value: 'team:' + name,
|
|
||||||
label: 'Team ' + name,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const makePage = (extra = {}) => ({
|
|
||||||
component: 'Teams',
|
|
||||||
url: '/teams',
|
|
||||||
version: null,
|
|
||||||
clearHistory: false,
|
|
||||||
encryptHistory: false,
|
|
||||||
props: {
|
|
||||||
name: 'KanbanGit',
|
|
||||||
errors: {},
|
|
||||||
sidebarOpen: true,
|
|
||||||
appearance: 'system',
|
|
||||||
auth: {
|
|
||||||
user: users[0],
|
|
||||||
permissions: {
|
|
||||||
globalAdmin: false,
|
|
||||||
projectCreate: false,
|
|
||||||
canManageTeams: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
navigation: { project: null },
|
|
||||||
...initial(),
|
|
||||||
...extra,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (serve) {
|
|
||||||
let data = initial();
|
|
||||||
server.middlewares.use(async (request, response, next) => {
|
|
||||||
if (!request.url.startsWith('/teams')) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
const url = new URL(request.url, 'http://localhost');
|
|
||||||
if (url.searchParams.has('reset')) {
|
|
||||||
data = initial();
|
|
||||||
}
|
|
||||||
if (url.searchParams.has('manager')) {
|
|
||||||
data.canCreateTeams = false;
|
|
||||||
}
|
|
||||||
if (url.searchParams.has('empty')) {
|
|
||||||
data.teams = [];
|
|
||||||
}
|
|
||||||
let errors = {};
|
|
||||||
if (request.method !== 'GET') {
|
|
||||||
const result =
|
|
||||||
request.headers.cookie?.match(/teams_result=(\w+)/)?.[1] ??
|
|
||||||
'success';
|
|
||||||
if (['server', 'forbidden'].includes(result)) {
|
|
||||||
response.statusCode = result === 'forbidden' ? 403 : 503;
|
|
||||||
response.end('Simulated failure');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let body = '';
|
|
||||||
for await (const chunk of request) {
|
|
||||||
body += chunk;
|
|
||||||
}
|
|
||||||
const submitted = JSON.parse(body || '{}');
|
|
||||||
if (result === 'validation') {
|
|
||||||
errors = url.pathname.endsWith('/members')
|
|
||||||
? { 'user_ids.0': 'This user is unavailable.' }
|
|
||||||
: url.pathname.includes('/permissions/')
|
|
||||||
? { 'permissions.0': 'This permission is unavailable.' }
|
|
||||||
: { name: 'This team name is already taken.' };
|
|
||||||
} else if (request.method === 'POST') {
|
|
||||||
data.teams.push({
|
|
||||||
id: 3,
|
|
||||||
name: submitted.name,
|
|
||||||
user_ids: [],
|
|
||||||
users: [],
|
|
||||||
});
|
|
||||||
} else if (url.pathname.endsWith('/members')) {
|
|
||||||
const team = data.teams.find(
|
|
||||||
(team) => team.id === Number(url.pathname.split('/')[2]),
|
|
||||||
);
|
|
||||||
team.user_ids = submitted.user_ids;
|
|
||||||
team.users = users.filter((user) =>
|
|
||||||
team.user_ids.includes(user.id),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const [, , teamId, , , userId] = url.pathname.split('/');
|
|
||||||
data.teamPermissions[teamId] ??= {};
|
|
||||||
data.teamPermissions[teamId][userId] = submitted.permissions;
|
|
||||||
}
|
|
||||||
if (Object.keys(errors).length === 0) {
|
|
||||||
data.status = 'Saved';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const page = makePage({ ...data, errors });
|
|
||||||
response.setHeader('Vary', 'X-Inertia');
|
|
||||||
if (request.headers['x-inertia']) {
|
|
||||||
response.setHeader('X-Inertia', 'true');
|
|
||||||
response.setHeader('Content-Type', 'application/json');
|
|
||||||
response.end(JSON.stringify(page));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const escaped = JSON.stringify(page)
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll('<', '<');
|
|
||||||
response.setHeader('Content-Type', 'text/html');
|
|
||||||
response.end(
|
|
||||||
await server.transformIndexHtml(
|
|
||||||
request.url,
|
|
||||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
|
||||||
escaped +
|
|
||||||
'"></div><script type="module" src="/tests/Fixtures/teams-preview.mjs"></script></body></html>',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await server.listen();
|
|
||||||
console.log('Teams fixture ready.');
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const { createLocalization } = await server.ssrLoadModule(
|
|
||||||
'/resources/js/lib/localization.ts',
|
|
||||||
);
|
|
||||||
const { createAppearance } = await server.ssrLoadModule(
|
|
||||||
'/resources/js/composables/useAppearance.ts',
|
|
||||||
);
|
|
||||||
const { en } = await server.ssrLoadModule(
|
|
||||||
'/resources/js/locales/en.ts',
|
|
||||||
);
|
|
||||||
const component = (
|
|
||||||
await server.ssrLoadModule('/resources/js/pages/teams/Index.vue')
|
|
||||||
).default;
|
|
||||||
const render = async (extra = {}, locale = createLocalization()) =>
|
|
||||||
(
|
|
||||||
await createInertiaApp({
|
|
||||||
page: makePage(extra),
|
|
||||||
resolve: () => component,
|
|
||||||
render: renderToString,
|
|
||||||
setup: ({ App, props, plugin }) =>
|
|
||||||
createSSRApp({ render: () => h(App, props) })
|
|
||||||
.use(plugin)
|
|
||||||
.use(locale)
|
|
||||||
.use(createAppearance()),
|
|
||||||
})
|
|
||||||
).body;
|
|
||||||
const html = await render();
|
|
||||||
assert.match(html, /Platform team/);
|
|
||||||
assert.match(html, /1 member/);
|
|
||||||
assert.match(html, /0 members/);
|
|
||||||
assert.match(html, /Manage members of Platform team/);
|
|
||||||
assert.match(html, /Manage permissions for Platform team/);
|
|
||||||
assert.match(html, /No members yet/);
|
|
||||||
assert.match(html, /Create team/);
|
|
||||||
assert.doesNotMatch(html, /Global admin|Hold Cmd|<select/);
|
|
||||||
assert.doesNotMatch(
|
|
||||||
await render({ canCreateTeams: false }),
|
|
||||||
/Create team/,
|
|
||||||
);
|
|
||||||
assert.match(await render({ teams: [] }), /No teams to manage/);
|
|
||||||
const translated = await render(
|
|
||||||
{},
|
|
||||||
createLocalization(
|
|
||||||
{ locale: 'de' },
|
|
||||||
{
|
|
||||||
de: {
|
|
||||||
...en,
|
|
||||||
'navigation.teams': 'Arbeitsgruppen',
|
|
||||||
'teams.create': 'Team anlegen',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
assert.match(translated, /Arbeitsgruppen/);
|
|
||||||
assert.match(translated, /Team anlegen/);
|
|
||||||
console.log('Teams presentation checks passed.');
|
|
||||||
} finally {
|
|
||||||
await server.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||