1
0
Fork 0
mirror of https://github.com/Luzifer/vault-otp-ui.git synced 2024-11-08 16:20:06 +00:00

Rewrite frontend on ES6 with Vue rendering

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2019-09-09 19:04:04 +02:00
parent ff37285740
commit ab4b20f015
Signed by: luzifer
GPG key ID: DC2729FDD34BE99E
3 changed files with 251 additions and 440 deletions

View file

@ -1,154 +0,0 @@
currentTimeout = 0
clipboard = null
preFetch = null
fetchInProgress = false
serverConnectionError = false
iterationCurrent = 'current'
iterationNext = 'next'
# document-ready function to start Javascript processing
$ ->
if $('body').hasClass 'state-signedin'
initializeApplication()
# createOTPItem generates code entries from the JSON objects passed from the backend
createOTPItem = (item) ->
tpl = $('#tpl-otp-item').html()
otpItem = $(tpl)
otpItem.find('.badge').text item.code.replace(/^([0-9]{3})([0-9]{3})$/, '$1 $2').replace(/^([0-9]{2})([0-9]{3})([0-9]{3})$/, '$1 $2 $3')
otpItem.find('.title').text item.name
otpItem.find('i.fa').addClass "fa-#{item.icon}"
otpItem.appendTo $('#keylist')
# createAlert adds a colored message at the top of the list
# type = success / info / warning / danger
createAlert = (type, keyword, message, timeout) ->
tpl = $('#tpl-message').html()
alrt = $(tpl)
alrt.find('.alert').addClass "alert-#{type}"
alrt.find('.alert').find('.keyword').text keyword
alrt.find('.alert').find('.message').text message
alrt.appendTo $('#messagecontainer')
if timeout > 0
delay timeout, () ->
alrt.remove()
# delay is a convenience wrapper to swap parameters of setTimeout
delay = (delayMSecs, fkt) ->
window.setTimeout fkt, delayMSecs
# fetchCodes contacts the backend to receive JSON containing current codes
fetchCodes = (iteration) ->
if fetchInProgress
return
fetchInProgress = true
if iteration == iterationCurrent
successFunc = updateCodes
else
successFunc = updatePreFetch
if iteration == iterationCurrent and preFetch != null
data = preFetch
preFetch = null
successFunc data
return
$.ajax
url: "codes.json?it=#{iteration}",
success: successFunc,
dataType: 'json',
error: () ->
fetchInProgress = false
createAlert 'danger', 'Oops.', 'Server could not be contacted. Maybe you (or the server) are offline? Reload to try again.', 0
serverConnectionError = true
statusCode:
401: () ->
window.location.reload()
500: () ->
fetchInProgress = false
createAlert 'danger', 'Oops.', 'The server responded with an internal error. Reload to try again.', 0
serverConnectionError = true
# filterChange is called when changing the filter field and matches the
# titles of all shown entries. Those not matching the given regular expression
# will be hidden. The filterChange function is also called after a successful
# refresh of the shown codes to re-apply
filterChange = () ->
filter = $('#filter').val().toLowerCase()
$('.otp-item').each (idx, el) ->
if $(el).find('.title').text().toLowerCase().match(filter) == null
$(el).hide()
else
$(el).show()
# initializeApplication initializes some basic events and starts the first
# polling for codes
initializeApplication = () ->
$('#keylist').empty()
$('#filter').bind 'keyup', filterChange
tick 500, refreshTimerProgress
fetchCodes iterationCurrent
# refreshTimerProgress updates the top progressbar to display the
# remaining time until the one-time-passwords changes
refreshTimerProgress = () ->
secondsLeft = timeLeft()
$('#timer').css 'width', "#{secondsLeft / 30 * 100}%"
if secondsLeft < 10 and preFetch == null and not serverConnectionError
# Do a pre-fetch to provide a seamless experience
fetchCodes iterationNext
# tick is a convenience wrapper to swap parameters of setInterval
tick = (delay, fkt) ->
window.setInterval fkt, delay
# timeLeft calculates the remaining time until codes get invalid
timeLeft = () ->
now = new Date().getTime()
(currentTimeout - now) / 1000
# updateCodes is being called when the backend delivered codes. The codes
# are then rendered and the clipboard methods are re-bound. Afterwards the
# next fetchCodes call is timed to that moment when the codes are getting
# invalid
updateCodes = (data) ->
currentTimeout = new Date(data.next_wrap).getTime()
if clipboard
clipboard.destroy()
$('#initLoader').hide()
$('#keylist').empty()
for token in data.tokens
createOTPItem token
clipboard = new Clipboard '.otp-item',
text: (trigger) ->
$(trigger).find('.badge').text().replace(/ /g, '')
clipboard.on 'success', (e) ->
createAlert 'success', 'Success:', 'Code copied to clipboard', 1000
e.blur()
clipboard.on 'error', (e) ->
createAlert 'danger', 'Oops.', 'Copy to clipboard failed', 2000
filterChange()
delay timeLeft()*1000, ->
fetchCodes iterationCurrent
fetchInProgress = false
updatePreFetch = (data) ->
preFetch = data
fetchInProgress = false

View file

@ -1,189 +1,161 @@
// Generated by CoffeeScript 2.3.1 const iterationCurrent = 'current'
(function() { const iterationNext = 'next'
var clipboard, createAlert, createOTPItem, currentTimeout, delay, fetchCodes, fetchInProgress, filterChange, initializeApplication, iterationCurrent, iterationNext, preFetch, refreshTimerProgress, serverConnectionError, tick, timeLeft, updateCodes, updatePreFetch;
currentTimeout = 0; const app = new Vue({
clipboard = null; computed: {
filteredItems() {
if (this.filter === '') {
return this.otpItems
}
preFetch = null; const items = []
fetchInProgress = false; for (let i of this.otpItems) {
if (i.name.toLowerCase().match(this.filter.toLowerCase())) {
serverConnectionError = false; items.push(i)
iterationCurrent = 'current';
iterationNext = 'next';
// document-ready function to start Javascript processing
$(function() {
if ($('body').hasClass('state-signedin')) {
return initializeApplication();
}
});
// createOTPItem generates code entries from the JSON objects passed from the backend
createOTPItem = function(item) {
var otpItem, tpl;
tpl = $('#tpl-otp-item').html();
otpItem = $(tpl);
otpItem.find('.badge').text(item.code.replace(/^([0-9]{3})([0-9]{3})$/, '$1 $2').replace(/^([0-9]{2})([0-9]{3})([0-9]{3})$/, '$1 $2 $3'));
otpItem.find('.title').text(item.name);
otpItem.find('i.fa').addClass(`fa-${item.icon}`);
return otpItem.appendTo($('#keylist'));
};
// createAlert adds a colored message at the top of the list
// type = success / info / warning / danger
createAlert = function(type, keyword, message, timeout) {
var alrt, tpl;
tpl = $('#tpl-message').html();
alrt = $(tpl);
alrt.find('.alert').addClass(`alert-${type}`);
alrt.find('.alert').find('.keyword').text(keyword);
alrt.find('.alert').find('.message').text(message);
alrt.appendTo($('#messagecontainer'));
if (timeout > 0) {
return delay(timeout, function() {
return alrt.remove();
});
}
};
// delay is a convenience wrapper to swap parameters of setTimeout
delay = function(delayMSecs, fkt) {
return window.setTimeout(fkt, delayMSecs);
};
// fetchCodes contacts the backend to receive JSON containing current codes
fetchCodes = function(iteration) {
var data, successFunc;
if (fetchInProgress) {
return;
}
fetchInProgress = true;
if (iteration === iterationCurrent) {
successFunc = updateCodes;
} else {
successFunc = updatePreFetch;
}
if (iteration === iterationCurrent && preFetch !== null) {
data = preFetch;
preFetch = null;
successFunc(data);
return;
}
return $.ajax({
url: `codes.json?it=${iteration}`,
success: successFunc,
dataType: 'json',
error: function() {
fetchInProgress = false;
createAlert('danger', 'Oops.', 'Server could not be contacted. Maybe you (or the server) are offline? Reload to try again.', 0);
return serverConnectionError = true;
},
statusCode: {
401: function() {
return window.location.reload();
},
500: function() {
fetchInProgress = false;
createAlert('danger', 'Oops.', 'The server responded with an internal error. Reload to try again.', 0);
return serverConnectionError = true;
} }
} }
});
};
// filterChange is called when changing the filter field and matches the return items
// titles of all shown entries. Those not matching the given regular expression },
// will be hidden. The filterChange function is also called after a successful },
// refresh of the shown codes to re-apply
filterChange = function() { data: {
var filter; authUrl,
filter = $('#filter').val().toLowerCase(); backoff: 500,
return $('.otp-item').each(function(idx, el) { currentTimeout: null,
if ($(el).find('.title').text().toLowerCase().match(filter) === null) { fetchInProgress: false,
return $(el).hide(); filter: '',
} else { lastFetch: null,
return $(el).show(); loading: true,
preFetch: null,
signedIn,
otpItems: [],
timeLeftPerc: 0.0,
},
el: '#application',
methods: {
// Let user know whether the copy command was successful
codeCopyResult(success) {
this.createAlert(success ? 'success' : 'danger', 'Copy to clipboard...', 'Code copied to clipboard')
},
// Wrapper around toast creation
createAlert(variant, title, text, autoHideDelay=2000) {
this.$bvToast.toast(text, {
autoHideDelay,
title,
toaster: 'b-toaster-bottom-center',
variant,
})
},
// Main functionality: Fetch codes, handle errors including backoff
fetchCodes(iteration=iterationCurrent) {
if (this.fetchInProgress || !this.signedIn) {
return
} }
});
};
// initializeApplication initializes some basic events and starts the first if (this.lastFetch && this.lastFetch.getTime() + this.backoff > new Date().getTime()) {
// polling for codes // slow down spammy requests
initializeApplication = function() { return
$('#keylist').empty();
$('#filter').bind('keyup', filterChange);
tick(500, refreshTimerProgress);
return fetchCodes(iterationCurrent);
};
// refreshTimerProgress updates the top progressbar to display the
// remaining time until the one-time-passwords changes
refreshTimerProgress = function() {
var secondsLeft;
secondsLeft = timeLeft();
$('#timer').css('width', `${secondsLeft / 30 * 100}%`);
if (secondsLeft < 10 && preFetch === null && !serverConnectionError) {
// Do a pre-fetch to provide a seamless experience
return fetchCodes(iterationNext);
}
};
// tick is a convenience wrapper to swap parameters of setInterval
tick = function(delay, fkt) {
return window.setInterval(fkt, delay);
};
// timeLeft calculates the remaining time until codes get invalid
timeLeft = function() {
var now;
now = new Date().getTime();
return (currentTimeout - now) / 1000;
};
// updateCodes is being called when the backend delivered codes. The codes
// are then rendered and the clipboard methods are re-bound. Afterwards the
// next fetchCodes call is timed to that moment when the codes are getting
// invalid
updateCodes = function(data) {
var i, len, ref, token;
currentTimeout = new Date(data.next_wrap).getTime();
if (clipboard) {
clipboard.destroy();
}
$('#initLoader').hide();
$('#keylist').empty();
ref = data.tokens;
for (i = 0, len = ref.length; i < len; i++) {
token = ref[i];
createOTPItem(token);
}
clipboard = new Clipboard('.otp-item', {
text: function(trigger) {
return $(trigger).find('.badge').text().replace(/ /g, '');
} }
}); this.lastFetch = new Date()
clipboard.on('success', function(e) {
createAlert('success', 'Success:', 'Code copied to clipboard', 1000);
return e.blur();
});
clipboard.on('error', function(e) {
return createAlert('danger', 'Oops.', 'Copy to clipboard failed', 2000);
});
filterChange();
delay(timeLeft() * 1000, function() {
return fetchCodes(iterationCurrent);
});
return fetchInProgress = false;
};
updatePreFetch = function(data) { this.fetchInProgress=true
preFetch = data;
return fetchInProgress = false; let successFunc= iteration == iterationCurrent ? this.updateCodes : this.updatePreFetch
}; if (iteration == iterationCurrent && this.preFetch !== null) {
successFunc(this.preFetch)
this.fetchInProgress = false
return
}
axios.get(`codes.json?it=${iteration}`)
.then(resp => {
successFunc(resp.data)
this.backoff = 500 // Reset backoff to 500ms
})
.catch(err => {
this.backoff = this.backoff * 1.5 > 30000 ? 30000 : this.backoff * 1.5
if (err.response && err.response.status) {
switch (err.response.status) {
case 401:
this.createAlert('danger', 'Logged out...', 'Server has no valid token for you: You need to re-login.')
this.signedIn = false
this.otpItems = []
break
case 500:
this.createAlert('danger', 'Oops.', `Something went wrong when fetching your codes, will try again in ${Math.round(this.backoff / 1000)}s...`, this.backoff)
break;
}
} else {
console.error(err)
this.createAlert('danger', 'Oops.', `The request went wrong, will try again in ${Math.round(this.backoff / 1000)}s...`, this.backoff)
}
if (iteration === iterationCurrent) {
this.otpItems = []
this.loading = true
}
})
.finally(() => { this.fetchInProgress=false })
},
// Format code for better readability: 000 000 or 00 000 000
formatCode(code) {
return code.replace(/^([0-9]{3})([0-9]{3})$/, '$1 $2').replace(/^([0-9]{2})([0-9]{3})([0-9]{3})$/, '$1 $2 $3')
},
// Update timer bar and trigger re-fetch of codes by time remaining
refreshTimerProgress() {
const secondsLeft = this.timeLeft()
this.timeLeftPerc = secondsLeft / 30 * 100
if (secondsLeft < 3 && !this.preFetch && this.signedIn) {
// Do a pre-fetch to provide a seamless experience
this.fetchCodes(secondsLeft < 0 ? iterationCurrent : iterationNext)
}
},
// Calculate remaining time for the current batch of codes
timeLeft() {
if (!this.currentTimeout) {
return 0
}
const now = new Date().getTime()
return (this.currentTimeout.getTime() - now) / 1000
},
// Update displayed codes
updateCodes(data) {
this.currentTimeout = new Date(data.next_wrap)
this.otpItems = data.tokens
this.loading = false
this.preFetch = null
window.setTimeout(this.fetchCodes, this.timeLeft()*1000)
},
// Store received data for later usage
updatePreFetch(data) {
this.preFetch = data
},
},
// Initialize application
mounted() {
window.setInterval(this.refreshTimerProgress, 500)
this.fetchCodes(iterationCurrent)
},
})
}).call(this);

View file

@ -9,142 +9,135 @@
<link rel="manifest" href="static/manifest.json"> <link rel="manifest" href="static/manifest.json">
<!-- Bootstrap --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/css/bootstrap.min.css"
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha256-YLGeXaapI0/5IgZopewRJcFXomhRMlYYjugPLSyNjTY=" crossorigin="anonymous">
integrity="sha256-LA89z+k9fjgMKQ/kq4OO2Mrf8VltYml/VES+Rg0fh20=" crossorigin="anonymous" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@4.3.1/dist/flatly/bootstrap.min.css"
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootswatch/4.0.0/flatly/bootstrap.min.css" integrity="sha256-0mhswpc4tUm8b+EHmWyk817AlGI+X5NmVsKbJkQ342c=" crossorigin="anonymous">
integrity="sha256-uR+4waFpGD6COqOrSkm2HiJwc668qfgW3kidhhaOyYk=" crossorigin="anonymous" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-vue@2.0.0/dist/bootstrap-vue.min.css"
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" integrity="sha256-98fnCXYEILg6wOwaFWPVePJcizsYZG2U+N95WSWsG3g=" crossorigin="anonymous">
integrity="sha256-eZrrJcwDc/3uDhsdt61sL2oOBY362qM3lon1gyExkL0=" crossorigin="anonymous" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css"
integrity="sha256-eZrrJcwDc/3uDhsdt61sL2oOBY362qM3lon1gyExkL0=" crossorigin="anonymous">
<style> <style>
body { font-size: 16px; padding-top: 90px; } body { font-size: 16px; padding-top: 90px; }
i { margin-right: 0.4em; } i { margin-right: 0.4em; }
#initLoader i { margin-right: unset; } .initLoader i { margin-right: unset; }
#templates { display: none; } .alert { background-image: none; }
.alert { background-image: none; }
.badge { background-color: #2980b9; color: #ddd; font-size: 15px; font-weight: bold; margin-top: 3px; } .badge { background-color: #2980b9; color: #ddd; font-size: 15px; font-weight: bold; margin-top: 3px; }
.center { text-align: center; } .center { text-align: center; }
.fixed { bottom: 0; position: fixed; width: 100%; z-index: 999; } .fixed { bottom: 0; position: fixed; width: 100%; z-index: 999; }
.jumbotron h2 { text-align: center; } .jumbotron h2 { text-align: center; }
.otp-item { cursor: pointer; }
.otp-item i { width: 1.1em; } .otp-item i { width: 1.1em; }
.pbar { background-color: #18BC9C; height: 100%; } .pbar { background-color: #18BC9C; height: 100%; }
.pcontainer { background-color: #E74C3C; border-width: 1px 0 1px 0; border-color: #333; height: 3px; position: absolute; bottom: 0; left: 0; width: 100%; z-index: 999; } .pcontainer { background-color: #E74C3C; border-width: 1px 0 1px 0; border-color: #333; height: 3px; position: absolute; bottom: 0; left: 0; width: 100%; z-index: 999; }
.state-signedin #login { display: none; }
.state-signedout #application { display: none; }
</style> </style>
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js"
integrity="sha256-3Jy/GbSLrg0o9y5Z5n1uw0qxZECH7C6OQpVBgNFYa0g=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/respond.js/1.4.2/respond.min.js"
integrity="sha256-g6iAfvZp+nDQ2TdTR/VVKJf3bGro4ub5fvWSWVRi2NE=" crossorigin="anonymous"></script>
<![endif]-->
</head> </head>
<body class="state-{{ if .isloggedin }}signedin{{ else }}signedout{{ end }}"> <body>
<nav class="navbar fixed-top navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand mr-5" href="#">Vault OTP-UI</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<form class="form-inline mr-auto">
<input type="text" class="form-control" placeholder="Filter" id="filter">
</form>
<ul class="navbar-nav my-2 my-lg-0">
<li class="nav-item">
<a class="nav-link" href="https://github.com/Luzifer/vault-otp-ui"><i class="fa fa-github" aria-hidden="true"></i> Source on Github</a>
</li>
</ul>
</div><!-- /.navbar-collapse -->
</div><!-- /.container-fluid -->
<div class="pcontainer">
<div class="pbar" style="width: 0%;" id="timer"></div>
</div>
</nav>
<div id="application"> <div id="application">
<div class="container-fluid fixed">
<div class="row justify-content-md-center"> <nav class="navbar fixed-top navbar-expand-lg navbar-dark bg-primary">
<div class="col-xs-10 col-sm-10 col-md-8 col-lg-4" id="messagecontainer"></div> <div class="container-fluid">
<a class="navbar-brand mr-5" href="#">Vault OTP-UI</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<form class="form-inline mr-auto">
<input type="text" class="form-control" placeholder="Filter" v-model="filter">
</form>
<ul class="navbar-nav my-2 my-lg-0">
<li class="nav-item">
<a class="nav-link" href="https://github.com/Luzifer/vault-otp-ui"><i class="fa fa-github" aria-hidden="true"></i> Source on Github</a>
</li>
</ul>
</div><!-- /.navbar-collapse -->
</div><!-- /.container-fluid -->
<div class="pcontainer">
<div class="pbar" :style="{ width: `${timeLeftPerc}%` }" id="timer"></div>
</div> </div>
</div> </nav>
<div class="container">
<div class="row justify-content-md-center"> <div v-if="signedIn">
<div class="col-xs-10 col-md-4 center" id="initLoader">
<i class="fa fa-refresh fa-spin fa-5x"></i><br> <div class="container">
</div>
<div class="w-100"></div> <div class="row justify-content-md-center">
<div class="col-xs-12 col-sm-8 col-md-6 col-lg-6"> <div class="col-xs-10 col-md-4 center initLoader" v-if="loading">
<div class="list-group" id="keylist"> <i class="fa fa-refresh fa-spin fa-5x"></i><br>
<!-- FIXME: Remove this --> </div>
<div class="w-100"></div>
<div class="col-xs-12 col-sm-8 col-md-6 col-lg-6">
<div class="list-group" id="keylist">
<a
class="list-group-item d-flex justify-content-between align-items-center otp-item"
v-for="item in filteredItems"
:key="item.name"
v-clipboard:copy="item.code"
v-clipboard:success="() => codeCopyResult(true)"
v-clipboard:error="() => codeCopyResult(false)"
>
<span>
<i :class="`fa fa-fw fa-${item.icon}`"></i>
<span class="title">{{ item.name }}</span>
</span>
<span class="badge">{{ formatCode(item.code) }}</span>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-else>
<div class="container">
<div class="row">
<div class="col-xs-12 col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3">
<div class="panel panel-default">
<div class="panel-heading">Please sign in!</div>
<div class="panel-body">
<p>
Use Github authentication to sign into your Vault instance and get access to your one-time passwords:
</p>
<p class="center">
<a :href="authurl" class="btn btn-primary"><i class="fa fa-github" aria-hidden="true"></i> Sign-in with Github</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div> <!-- /#login -->
</div> <!-- /#application --> </div> <!-- /#application -->
<div id="login"> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"
integrity="sha256-chlNFSVx3TdcQ2Xlw7SvnbLAavAQLO0Y/LBiWX04viY="
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-vue@2.0.0/dist/bootstrap-vue.min.js"
integrity="sha256-Hv63vpX6fRHvM0UYK/NJMbAZ81/6IHQfkkq5BSHYXP8="
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-clipboard2@0.3.1/dist/vue-clipboard.min.js"
integrity="sha256-XvHL1mhvDUwfYL9UgYaEG0TBKZg3J9uScjUDG6oCS6k="
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.19.0/dist/axios.min.js"
integrity="sha256-S1J4GVHHDMiirir9qsXWc8ZWw74PHHafpsHp5PXtjTs="
crossorigin="anonymous"></script>
<div class="container"> <script src="vars.js"></script>
<div class="row">
<div class="col-xs-12 col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3">
<div class="panel panel-default">
<div class="panel-heading">Please sign in!</div>
<div class="panel-body">
<p>
Use Github authentication to sign into your Vault instance and get access to your one-time passwords:
</p>
<p class="center">
<a href="{{ .authurl }}" class="btn btn-primary"><i class="fa fa-github" aria-hidden="true"></i> Sign-in with Github</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div> <!-- /#login -->
<div id="templates">
<div id="tpl-otp-item">
<a class="list-group-item d-flex justify-content-between align-items-center otp-item">
<span>
<i class="fa fa-fw"></i>
<span class="title">Some Site</span>
</span>
<span class="badge">145 369</span>
</a>
</div>
<div id="tpl-message">
<div class="row">
<div class="alert alert-dismissible col-sm-12 col-xs-12 col-md-12 col-lg-12" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<strong class="keyword">Warning!</strong> <span class="message"></span>
</div>
</div>
</div>
</div>
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"
integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
<!-- Include all compiled plugins (below), or include individual files as needed -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0/js/bootstrap.min.js"
integrity="sha256-5+02zu5UULQkO7w1GIr6vftCgMfFdZcAHeDtFnKZsBs=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/1.7.1/clipboard.min.js"
integrity="sha256-Daf8GuI2eLKHJlOWLRR/zRy9Clqcj4TUSumbxYH9kGI=" crossorigin="anonymous"></script>
<!-- Application specific JavaScript -->
<script src="application.js"></script> <script src="application.js"></script>
</body> </body>
</html> </html>