✨ Pass login page
This commit is contained in:
		@@ -4,6 +4,7 @@
 | 
			
		||||
    "": {
 | 
			
		||||
      "name": "@solar-network/pass",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@fingerprintjs/fingerprintjs": "^4.6.2",
 | 
			
		||||
        "@fontsource-variable/nunito": "^5.2.6",
 | 
			
		||||
        "@tailwindcss/vite": "^4.1.11",
 | 
			
		||||
        "aspnet-prerendering": "^3.0.1",
 | 
			
		||||
@@ -131,6 +132,8 @@
 | 
			
		||||
 | 
			
		||||
    "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="],
 | 
			
		||||
 | 
			
		||||
    "@fingerprintjs/fingerprintjs": ["@fingerprintjs/fingerprintjs@4.6.2", "", { "dependencies": { "tslib": "^2.4.1" } }, "sha512-g8mXuqcFKbgH2CZKwPfVtsUJDHyvcgIABQI7Y0tzWEFXpGxJaXuAuzlifT2oTakjDBLTK4Gaa9/5PERDhqUjtw=="],
 | 
			
		||||
 | 
			
		||||
    "@fontsource-variable/nunito": ["@fontsource-variable/nunito@5.2.6", "", {}, "sha512-dGYTQ0Hl94jjfMraYefrURHGH8fk/vL/1zYAZGofiPJVs6C0OkM8T87Te5Gwrbe6HG/XEMm5lib8AqasTN3ucw=="],
 | 
			
		||||
 | 
			
		||||
    "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@
 | 
			
		||||
    "format": "prettier --write src/"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@fingerprintjs/fingerprintjs": "^4.6.2",
 | 
			
		||||
    "@fontsource-variable/nunito": "^5.2.6",
 | 
			
		||||
    "@tailwindcss/vite": "^4.1.11",
 | 
			
		||||
    "aspnet-prerendering": "^3.0.1",
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,12 @@ const router = createRouter({
 | 
			
		||||
      path: '/spells/:word',
 | 
			
		||||
      name: 'spells',
 | 
			
		||||
      component: () => import('../views/spells.vue'),
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      path: '/login',
 | 
			
		||||
      name: 'login',
 | 
			
		||||
      component: () => import('../views/login.vue'),
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,342 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, onMounted, computed } from 'vue'
 | 
			
		||||
import { NCard, NSpace, NInput, NButton, NSpin, NAlert, NProgress } from 'naive-ui'
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
import FingerprintJS from '@fingerprintjs/fingerprintjs'
 | 
			
		||||
 | 
			
		||||
// State management
 | 
			
		||||
const stage = ref<'find-account' | 'select-factor' | 'enter-code' | 'token-exchange'>(
 | 
			
		||||
  'find-account',
 | 
			
		||||
)
 | 
			
		||||
const isLoading = ref(false)
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
 | 
			
		||||
// Stage 1: Find Account
 | 
			
		||||
const accountIdentifier = ref('')
 | 
			
		||||
const deviceId = ref('')
 | 
			
		||||
 | 
			
		||||
// Stage 2 & 3: Challenge
 | 
			
		||||
const challenge = ref<any>(null)
 | 
			
		||||
const factors = ref<any[]>([])
 | 
			
		||||
const selectedFactorId = ref<string | null>(null)
 | 
			
		||||
const password = ref('') // Used for password or verification code
 | 
			
		||||
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
 | 
			
		||||
// Generate deviceId based on browser fingerprint
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  const fp = await FingerprintJS.load()
 | 
			
		||||
  const result = await fp.get()
 | 
			
		||||
  deviceId.value = result.visitorId
 | 
			
		||||
  localStorage.setItem('deviceId', deviceId.value)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const selectedFactor = computed(() => {
 | 
			
		||||
  if (!selectedFactorId.value) return null
 | 
			
		||||
  return factors.value.find((f) => f.id === selectedFactorId.value)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
async function handleFindAccount() {
 | 
			
		||||
  if (!accountIdentifier.value) {
 | 
			
		||||
    error.value = 'Please enter your email or username.'
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
  isLoading.value = true
 | 
			
		||||
  error.value = null
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch('/api/auth/challenge', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
      body: JSON.stringify({
 | 
			
		||||
        platform: 1,
 | 
			
		||||
        account: accountIdentifier.value,
 | 
			
		||||
        device_id: deviceId.value,
 | 
			
		||||
      }),
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      const message = await response.text()
 | 
			
		||||
      throw new Error(message || 'Account not found.')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    challenge.value = await response.json()
 | 
			
		||||
    await getFactors()
 | 
			
		||||
    stage.value = 'select-factor'
 | 
			
		||||
  } catch (e: any) {
 | 
			
		||||
    error.value = e.message
 | 
			
		||||
  } finally {
 | 
			
		||||
    isLoading.value = false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getFactors() {
 | 
			
		||||
  isLoading.value = true
 | 
			
		||||
  error.value = null
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch(`/api/auth/challenge/${challenge.value.id}/factors`)
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      throw new Error('Could not fetch authentication factors.')
 | 
			
		||||
    }
 | 
			
		||||
    const availableFactors = await response.json()
 | 
			
		||||
    factors.value = availableFactors.filter(
 | 
			
		||||
      (f: any) => !challenge.value.blacklist_factors.includes(f.id),
 | 
			
		||||
    )
 | 
			
		||||
    if (factors.value.length > 0) {
 | 
			
		||||
      selectedFactorId.value = null // Let user choose
 | 
			
		||||
    } else if (challenge.value.step_remain > 0) {
 | 
			
		||||
      error.value =
 | 
			
		||||
        'No more available authentication factors, but authentication is not complete. Please contact support.'
 | 
			
		||||
    }
 | 
			
		||||
  } catch (e: any) {
 | 
			
		||||
    error.value = e.message
 | 
			
		||||
  } finally {
 | 
			
		||||
    isLoading.value = false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function requestVerificationCode(hint: string | null) {
 | 
			
		||||
  if (!selectedFactorId.value) return
 | 
			
		||||
 | 
			
		||||
  const isResend = stage.value === 'enter-code'
 | 
			
		||||
  if (isResend) isLoading.value = true
 | 
			
		||||
  error.value = null
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch(
 | 
			
		||||
      `/api/auth/challenge/${challenge.value.id}/factors/${selectedFactorId.value}`,
 | 
			
		||||
      {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
        body: JSON.stringify(hint),
 | 
			
		||||
      },
 | 
			
		||||
    )
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      const message = await response.text()
 | 
			
		||||
      throw new Error(message || 'Failed to send code.')
 | 
			
		||||
    }
 | 
			
		||||
  } catch (e: any) {
 | 
			
		||||
    error.value = e.message
 | 
			
		||||
    throw e // Rethrow to be handled by caller
 | 
			
		||||
  } finally {
 | 
			
		||||
    if (isResend) isLoading.value = false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function handleFactorSelected() {
 | 
			
		||||
  if (!selectedFactor.value) {
 | 
			
		||||
    error.value = 'Please select an authentication method.'
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // For password or TOTP, just move to the next step
 | 
			
		||||
  if (selectedFactor.value.type === 0 || selectedFactor.value.type === 2) {
 | 
			
		||||
    stage.value = 'enter-code'
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // For email, send the code first
 | 
			
		||||
  if (selectedFactor.value.type === 1) {
 | 
			
		||||
    isLoading.value = true
 | 
			
		||||
    error.value = null
 | 
			
		||||
    try {
 | 
			
		||||
      await requestVerificationCode(selectedFactor.value.contact)
 | 
			
		||||
      stage.value = 'enter-code'
 | 
			
		||||
    } catch {
 | 
			
		||||
      // Error is already set by requestVerificationCode
 | 
			
		||||
    } finally {
 | 
			
		||||
      isLoading.value = false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function handleVerifyFactor() {
 | 
			
		||||
  if (!selectedFactorId.value || !password.value) {
 | 
			
		||||
    error.value = 'Please enter your password/code.'
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
  isLoading.value = true
 | 
			
		||||
  error.value = null
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch(`/api/auth/challenge/${challenge.value.id}`, {
 | 
			
		||||
      method: 'PATCH',
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
      body: JSON.stringify({
 | 
			
		||||
        factor_id: selectedFactorId.value,
 | 
			
		||||
        password: password.value,
 | 
			
		||||
      }),
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      const message = await response.text()
 | 
			
		||||
      throw new Error(message || 'Verification failed.')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    challenge.value = await response.json()
 | 
			
		||||
    password.value = ''
 | 
			
		||||
 | 
			
		||||
    if (challenge.value.step_remain === 0) {
 | 
			
		||||
      stage.value = 'token-exchange'
 | 
			
		||||
      await exchangeToken()
 | 
			
		||||
    } else {
 | 
			
		||||
      await getFactors()
 | 
			
		||||
      stage.value = 'select-factor' // MFA step
 | 
			
		||||
    }
 | 
			
		||||
  } catch (e: any) {
 | 
			
		||||
    error.value = e.message
 | 
			
		||||
  } finally {
 | 
			
		||||
    isLoading.value = false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function exchangeToken() {
 | 
			
		||||
  isLoading.value = true
 | 
			
		||||
  error.value = null
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch('/api/auth/token', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
      body: JSON.stringify({
 | 
			
		||||
        grant_type: 'authorization_code',
 | 
			
		||||
        code: challenge.value.id,
 | 
			
		||||
      }),
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      const message = await response.text()
 | 
			
		||||
      throw new Error(message || 'Token exchange failed.')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { token } = await response.json()
 | 
			
		||||
    localStorage.setItem('authToken', token)
 | 
			
		||||
    await router.push('/')
 | 
			
		||||
  } catch (e: any) {
 | 
			
		||||
    error.value = e.message
 | 
			
		||||
    stage.value = 'select-factor' // Go back if token exchange fails
 | 
			
		||||
  } finally {
 | 
			
		||||
    isLoading.value = false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getFactorName(factorType: number) {
 | 
			
		||||
  switch (factorType) {
 | 
			
		||||
    case 0:
 | 
			
		||||
      return 'Password'
 | 
			
		||||
    case 1:
 | 
			
		||||
      return 'Email'
 | 
			
		||||
    case 2:
 | 
			
		||||
      return 'Authenticator App'
 | 
			
		||||
    default:
 | 
			
		||||
      return 'Unknown Factor'
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex items-center justify-center h-full">
 | 
			
		||||
    <n-card class="w-full max-w-md" title="Login">
 | 
			
		||||
      <n-spin :show="isLoading">
 | 
			
		||||
        <n-space vertical>
 | 
			
		||||
          <!-- Stage 1: Find Account -->
 | 
			
		||||
          <div v-if="stage === 'find-account'">
 | 
			
		||||
            <p>Welcome back!</p>
 | 
			
		||||
            <p class="mb-4">Login with your Solarpass.</p>
 | 
			
		||||
            <p class="mb-4">Enter your account identifier to continue.</p>
 | 
			
		||||
            <n-input
 | 
			
		||||
              v-model:value="accountIdentifier"
 | 
			
		||||
              placeholder="Email or Username"
 | 
			
		||||
              size="large"
 | 
			
		||||
              @keydown.enter="handleFindAccount"
 | 
			
		||||
              class="mb-4"
 | 
			
		||||
            />
 | 
			
		||||
            <n-button type="primary" block class="mt-4" size="large" @click="handleFindAccount">
 | 
			
		||||
              Continue
 | 
			
		||||
            </n-button>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Stage 2: Select Factor -->
 | 
			
		||||
          <div v-if="stage === 'select-factor' && challenge">
 | 
			
		||||
            <div class="flex items-center mb-4 gap-3">
 | 
			
		||||
              <span class="flex-shrink-1">Completeness</span>
 | 
			
		||||
              <n-progress
 | 
			
		||||
                type="line"
 | 
			
		||||
                :percentage="(1 - challenge.step_remain / challenge.step_total) * 100"
 | 
			
		||||
                indicator-placement="inside"
 | 
			
		||||
                class="flex-1"
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="flex flex-col gap-3">
 | 
			
		||||
              <n-card
 | 
			
		||||
                v-for="factor in factors"
 | 
			
		||||
                :key="factor.id"
 | 
			
		||||
                size="small"
 | 
			
		||||
                hoverable
 | 
			
		||||
                class="cursor-pointer"
 | 
			
		||||
                @click="
 | 
			
		||||
                  () => {
 | 
			
		||||
                    selectedFactorId = factor.id
 | 
			
		||||
                    handleFactorSelected()
 | 
			
		||||
                  }
 | 
			
		||||
                "
 | 
			
		||||
                :title="getFactorName(factor.type)"
 | 
			
		||||
              ></n-card>
 | 
			
		||||
            </div>
 | 
			
		||||
            <p class="text-center text-xs opacity-75 mt-3">Select a method to authenticate</p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Stage 3: Enter Code -->
 | 
			
		||||
          <div v-if="stage === 'enter-code' && selectedFactor">
 | 
			
		||||
            <h3 class="mb-3">
 | 
			
		||||
              Enter the {{ selectedFactor.type === 0 ? 'password' : 'verification code' }} to
 | 
			
		||||
              continue.
 | 
			
		||||
            </h3>
 | 
			
		||||
            <p v-if="selectedFactor.type === 1">
 | 
			
		||||
              A code has been sent to {{ selectedFactor.contact }}.
 | 
			
		||||
            </p>
 | 
			
		||||
            <p v-if="selectedFactor.type === 2">Enter the code from your authenticator app.</p>
 | 
			
		||||
            <n-input
 | 
			
		||||
              v-model:value="password"
 | 
			
		||||
              type="password"
 | 
			
		||||
              show-password-on="click"
 | 
			
		||||
              :placeholder="selectedFactor.type === 0 ? 'Password' : 'Code'"
 | 
			
		||||
              size="large"
 | 
			
		||||
              class="mb-4"
 | 
			
		||||
              @keydown.enter="handleVerifyFactor"
 | 
			
		||||
            />
 | 
			
		||||
            <n-space justify="end">
 | 
			
		||||
              <n-button
 | 
			
		||||
                v-if="selectedFactor.type === 1"
 | 
			
		||||
                text
 | 
			
		||||
                @click="requestVerificationCode(selectedFactor.contact)"
 | 
			
		||||
              >
 | 
			
		||||
                Resend Code
 | 
			
		||||
              </n-button>
 | 
			
		||||
            </n-space>
 | 
			
		||||
            <n-button type="primary" block class="mt-4" size="large" @click="handleVerifyFactor">
 | 
			
		||||
              Verify
 | 
			
		||||
            </n-button>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Stage 4: Token Exchange -->
 | 
			
		||||
          <div v-if="stage === 'token-exchange'">
 | 
			
		||||
            <h3 class="mb-4">Finalizing Login</h3>
 | 
			
		||||
            <n-spin />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <n-alert
 | 
			
		||||
            v-if="error"
 | 
			
		||||
            title="Error"
 | 
			
		||||
            type="error"
 | 
			
		||||
            closable
 | 
			
		||||
            @after-hide="error = null"
 | 
			
		||||
            class="mt-2"
 | 
			
		||||
          >
 | 
			
		||||
            {{ error }}
 | 
			
		||||
          </n-alert>
 | 
			
		||||
        </n-space>
 | 
			
		||||
      </n-spin>
 | 
			
		||||
    </n-card>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -97,6 +97,7 @@
 | 
			
		||||
          <AutoGen>True</AutoGen>
 | 
			
		||||
          <DependentUpon>SharedResource.resx</DependentUpon>
 | 
			
		||||
        </Compile>
 | 
			
		||||
        <Compile Remove="Client\**" />
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
@@ -132,6 +133,7 @@
 | 
			
		||||
          <Generator>ResXFileCodeGenerator</Generator>
 | 
			
		||||
          <LastGenOutput>SharedResource.Designer.cs</LastGenOutput>
 | 
			
		||||
        </EmbeddedResource>
 | 
			
		||||
        <EmbeddedResource Remove="Client\**" />
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
@@ -154,12 +156,19 @@
 | 
			
		||||
      <_ContentIncludedByDefault Remove="Pages\Shared\_Layout.cshtml" />
 | 
			
		||||
      <_ContentIncludedByDefault Remove="Pages\Checkpoint\CheckpointPage.cshtml" />
 | 
			
		||||
      <_ContentIncludedByDefault Remove="Pages\Spell\MagicSpellPage.cshtml" />
 | 
			
		||||
      <_ContentIncludedByDefault Remove="Client\.prettierrc.json" />
 | 
			
		||||
      <_ContentIncludedByDefault Remove="Client\package.json" />
 | 
			
		||||
      <_ContentIncludedByDefault Remove="Client\tsconfig.app.json" />
 | 
			
		||||
      <_ContentIncludedByDefault Remove="Client\tsconfig.json" />
 | 
			
		||||
      <_ContentIncludedByDefault Remove="Client\tsconfig.node.json" />
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
      <Folder Include="Client\public\" />
 | 
			
		||||
      <Folder Include="Client\src\components\" />
 | 
			
		||||
      <Folder Include="Client\src\stores\" />
 | 
			
		||||
      <Content Remove="Client\**" />
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
      <None Remove="Client\**" />
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
 | 
			
		||||
</Project>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user